From f81bdd548d0d7f6e624be5e0f9019e5b3e5e2261 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 03:58:29 +0200 Subject: [PATCH 01/11] chore: delete the manual rebuild engine and the daemon bulk-rebuild route Ordinary daemon convergence (the raw-materialization drain) is the only build path. Removes maintenance/rebuild_index.py, sharded_rebuild.py, replay.py, reindex_canary.py, daemon/bulk_rebuild.py, the rebuild-index, rebuild-index-status and reindex-canary CLI verbs, the four daemon HTTP routes that served them, the daemon loop's bulk-rebuild routing, the devtools rebuild-safety scenario, and the writer-less active_rebuild_index_attempts readers; next-action prose points at `polylogued run`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- devtools/command_catalog.py | 2 +- devtools/rebuild_safety_scenario.py | 685 ---- devtools/verification_scenario.py | 90 - .../cli/commands/maintenance/__init__.py | 18 - .../commands/maintenance/_rebuild_index.py | 595 ---- .../maintenance/_rebuild_index_status.py | 118 - .../commands/maintenance/_reindex_canary.py | 177 - polylogue/cli/commands/paths.py | 17 +- polylogue/cli/commands/reset.py | 13 +- polylogue/cli/commands/status_diagnostics.py | 2 +- polylogue/cli/convergence_feedback.py | 15 - polylogue/daemon/api_auth.py | 4 +- polylogue/daemon/bulk_rebuild.py | 764 ----- polylogue/daemon/cli.py | 227 +- polylogue/daemon/convergence_stages.py | 6 +- polylogue/daemon/embedding_backlog.py | 2 +- polylogue/daemon/http.py | 385 --- polylogue/daemon/maintenance_registry_http.py | 89 - polylogue/daemon/metrics.py | 14 +- polylogue/daemon/parse_prefetch.py | 9 +- polylogue/daemon/route_contracts.py | 578 ---- polylogue/daemon/status.py | 23 +- polylogue/maintenance/rebuild_index.py | 2756 --------------- polylogue/maintenance/reindex_canary.py | 3011 ----------------- polylogue/maintenance/replay.py | 1560 --------- polylogue/maintenance/sharded_rebuild.py | 620 ---- polylogue/readiness/__init__.py | 17 +- polylogue/readiness/claim_guard.py | 2 +- polylogue/sources/census_parse_stage.py | 38 +- polylogue/sources/revision_backfill.py | 25 +- polylogue/storage/archive_readiness.py | 70 +- polylogue/storage/sqlite/action_pairs.py | 4 +- .../sqlite/archive_tiers/ingest_precedence.py | 5 +- .../storage/sqlite/archive_tiers/write.py | 3 +- .../storage/sqlite/connection_profile.py | 10 +- polylogue/storage/sqlite/schema.py | 2 +- polylogue/storage/sqlite/schema_bootstrap.py | 2 +- tests/benchmarks/test_rebuild_cost_model.py | 173 - tests/benchmarks/test_sharded_rebuild.py | 240 -- tests/infra/rebuild_cost_model.py | 1034 ------ ...est_maintenance_operation_id_boundaries.py | 74 - tests/unit/cli/test_reindex_canary_cli.py | 1960 ----------- tests/unit/daemon/test_bulk_rebuild.py | 679 ---- .../daemon/test_bulk_rebuild_ownership.py | 158 - ...test_daemon_bulk_rebuild_responsiveness.py | 266 -- .../unit/daemon/test_maintenance_endpoints.py | 466 --- .../devtools/test_rebuild_safety_scenario.py | 176 - ...test_inactive_candidate_durable_barrier.py | 739 ---- .../test_rebuild_index_bulk_build.py | 160 - .../test_rebuild_index_candidate_promotion.py | 299 -- .../test_rebuild_index_deadline.py | 211 -- .../test_rebuild_index_lease_lifecycle.py | 143 - .../test_rebuild_index_ownership.py | 514 --- .../test_rebuild_index_phase_timing.py | 405 --- .../test_rebuild_index_provenance_gate.py | 1651 --------- .../test_rebuild_index_resume_correctness.py | 206 -- .../test_rebuild_index_selection.py | 176 - .../test_rebuild_parse_apply_split.py | 296 -- tests/unit/maintenance/test_rebuild_status.py | 210 -- .../unit/maintenance/test_reindex_campaign.py | 494 --- tests/unit/maintenance/test_reindex_canary.py | 2784 --------------- .../unit/maintenance/test_sharded_rebuild.py | 429 --- .../storage/test_planner_statistics_seed.py | 104 - .../test_rebuild_paging_content_order.py | 335 -- 64 files changed, 68 insertions(+), 26272 deletions(-) delete mode 100644 devtools/rebuild_safety_scenario.py delete mode 100644 polylogue/cli/commands/maintenance/_rebuild_index.py delete mode 100644 polylogue/cli/commands/maintenance/_rebuild_index_status.py delete mode 100644 polylogue/cli/commands/maintenance/_reindex_canary.py delete mode 100644 polylogue/daemon/bulk_rebuild.py delete mode 100644 polylogue/daemon/maintenance_registry_http.py delete mode 100644 polylogue/maintenance/rebuild_index.py delete mode 100644 polylogue/maintenance/reindex_canary.py delete mode 100644 polylogue/maintenance/replay.py delete mode 100644 polylogue/maintenance/sharded_rebuild.py delete mode 100644 tests/benchmarks/test_rebuild_cost_model.py delete mode 100644 tests/benchmarks/test_sharded_rebuild.py delete mode 100644 tests/infra/rebuild_cost_model.py delete mode 100644 tests/unit/cli/test_maintenance_operation_id_boundaries.py delete mode 100644 tests/unit/cli/test_reindex_canary_cli.py delete mode 100644 tests/unit/daemon/test_bulk_rebuild.py delete mode 100644 tests/unit/daemon/test_bulk_rebuild_ownership.py delete mode 100644 tests/unit/daemon/test_daemon_bulk_rebuild_responsiveness.py delete mode 100644 tests/unit/daemon/test_maintenance_endpoints.py delete mode 100644 tests/unit/devtools/test_rebuild_safety_scenario.py delete mode 100644 tests/unit/maintenance/test_inactive_candidate_durable_barrier.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_bulk_build.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_candidate_promotion.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_deadline.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_ownership.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_phase_timing.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_provenance_gate.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_resume_correctness.py delete mode 100644 tests/unit/maintenance/test_rebuild_index_selection.py delete mode 100644 tests/unit/maintenance/test_rebuild_parse_apply_split.py delete mode 100644 tests/unit/maintenance/test_rebuild_status.py delete mode 100644 tests/unit/maintenance/test_reindex_campaign.py delete mode 100644 tests/unit/maintenance/test_reindex_canary.py delete mode 100644 tests/unit/maintenance/test_sharded_rebuild.py delete mode 100644 tests/unit/storage/test_planner_statistics_seed.py delete mode 100644 tests/unit/storage/test_rebuild_paging_content_order.py diff --git a/devtools/command_catalog.py b/devtools/command_catalog.py index fb50046a66..dc8ac9297b 100644 --- a/devtools/command_catalog.py +++ b/devtools/command_catalog.py @@ -166,7 +166,7 @@ def to_dict(self) -> dict[str, object]: examples=( "devtools scenario list", "devtools scenario run archive-smoke --tier 0", - "devtools scenario run rebuild-safety --report-dir .cache/rebuild-safety-report --json", + "devtools scenario run storage-correctness --report-dir .cache/storage-correctness-report --json", ), ), CommandSpec( diff --git a/devtools/rebuild_safety_scenario.py b/devtools/rebuild_safety_scenario.py deleted file mode 100644 index b9117d2221..0000000000 --- a/devtools/rebuild_safety_scenario.py +++ /dev/null @@ -1,685 +0,0 @@ -"""Derived-tier rebuild-safety and rebuild-differential lab scenarios. - -Two related, evidence-driven proof obligations that neither had an -executable lane before this module (polylogue-1xc.8, polylogue-hjwr): - -1. **rebuild-safety** (1xc.8): resetting the derived tier (``index.db``) and - rebuilding it from durable ``source.db`` evidence must be lossless and - idempotent -- the same source replayed twice produces the same derived - content, and the durable ``user.db`` tier (assertions, corrections) is - never touched by a derived-tier reset. -2. **rebuild-differential** (hjwr): the two ways a session's derived rows - come to exist -- a full from-scratch rebuild - (``polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync``) - versus live incremental ingest (per-session - ``write_parsed_session_to_archive``) followed by the daemon's own - convergence-stage catch-up (``daemon.convergence_stages.make_insights_stage``) - -- must agree on the derived content they produce. ``polylogue-a7xr.2`` - (closed) was exactly a case where they didn't: the converger and - ``storage/repair.py`` encoded different staleness predicates for - ``session_profiles``, an inconsistency this scenario is the general, - ongoing gate against a *class* of, not merely that one instance. - -Both scenarios share one seed corpus (real, parseable Codex/Claude Code raws -via ``tests.infra.rebuild_cost_model.build_stratum_sample_corpus`` -- the -same synthesis already used by the stratified rebuild-cost benchmark, not a -new generator) and one census/diff engine: every derived (``index.db``) -table is either diffed or explicitly allowlisted with a documented reason -(``_ALLOWLISTED_DERIVED_TABLES``). Logical FTS surfaces are queried through -their MATCH/LIKE paths, while only opaque FTS shadow tables are allowlisted. -An undeclared new table fails the scenario instead of silently escaping -comparison (the auto-census requirement both beads' acceptance criteria -name). -""" - -from __future__ import annotations - -import os -import sqlite3 -from collections.abc import Iterator -from contextlib import contextmanager -from dataclasses import dataclass, field -from pathlib import Path -from tempfile import TemporaryDirectory - -from polylogue.core.enums import Provider -from polylogue.daemon.convergence_stages import make_insights_stage -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.pipeline.ids import session_content_hash -from polylogue.sources.dispatch import detect_provider, parse_payload -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.fts.sql import message_identity_mismatch_sql -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_tier -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive -from tests.infra.archive_canonical_snapshot import capture_canonical_snapshot -from tests.infra.live_ingest import write_session_sync -from tests.infra.pipeline_roundtrip import decode_source_payload, parse_payload_roundtrip -from tests.infra.rebuild_cost_model import Stratum, build_stratum_sample_corpus -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - -REBUILD_SAFETY_SCENARIO_NAME = "rebuild-safety" -REBUILD_DIFFERENTIAL_SCENARIO_NAME = "rebuild-differential" - -#: Columns excluded from every table's diff: wall-clock materialization -#: stamps, not content derived from source evidence. Two independently -#: timed rebuild passes of identical source content legitimately disagree -#: on *when* they ran without disagreeing on *what* they produced. -_VOLATILE_COLUMNS = frozenset({"materialized_at", "materialized_at_ms", "decided_at_ms"}) - -# ``session_profiles.priced_at_ms`` records when the pricing projection ran, -# not source evidence or the selected price catalog. It differs across two -# otherwise identical replay passes just like ``materialized_at_ms``. -_SESSION_PROFILE_VOLATILE_COLUMNS = _VOLATILE_COLUMNS | frozenset({"priced_at_ms"}) - -#: Tables intentionally excluded from the row-level diff, each for a -#: documented reason -- the census in ``_assert_every_table_covered`` fails -#: the scenario if a new derived table appears in neither this set nor the -#: diffed-table set, so growth in ``archive_tiers`` DDL cannot silently -#: escape this lane's coverage. -_ALLOWLISTED_DERIVED_TABLES = frozenset( - { - # FTS5 virtual tables and their opaque shadow-table internals: binary - # segment/index formats, not row content meaningfully comparable - # across two independently built FTS indexes of the same logical text. - "messages_fts_config", - "messages_fts_data", - "messages_fts_docsize", - "messages_fts_idx", - "messages_fts", - "blocks_command_trigram_config", - "blocks_command_trigram_data", - "blocks_command_trigram_docsize", - "blocks_command_trigram_idx", - "blocks_command_trigram", - "session_work_events_fts_config", - "session_work_events_fts_content", - "session_work_events_fts_data", - "session_work_events_fts_docsize", - "session_work_events_fts_idx", - "session_work_events_fts", - "threads_fts", - "threads_fts_config", - "threads_fts_content", - "threads_fts_data", - "threads_fts_docsize", - "threads_fts_idx", - # Seeded fixed reference data (pricing catalog), not derived from this - # scenario's source corpus at all -- identical on every fresh init. - "price_catalogs", - # Pure convergence/refresh bookkeeping: run-scoped progress markers, - # not content derived from source raws. - "fts_freshness_state", - "derived_refresh_guard", - "query_unit_frame_state", - # Replay provenance receipts are emitted by the full source replay - # engine, while the incremental index writer intentionally consumes - # already-frozen source authority without recreating those receipts. - # They are source/replay bookkeeping, not derived content; the - # source census and the content-bearing index tables remain compared. - "raw_revision_applications", - "raw_revision_heads", - } -) - -_LOGICAL_FTS_SURFACES = frozenset( - { - "messages_fts", - "blocks_command_trigram", - "session_work_events_fts", - } -) - - -@dataclass(frozen=True, slots=True) -class TableDiff: - table: str - only_in_a: tuple[tuple[object, ...], ...] - only_in_b: tuple[tuple[object, ...], ...] - - @property - def is_empty(self) -> bool: - return not self.only_in_a and not self.only_in_b - - -@dataclass(frozen=True, slots=True) -class RebuildComparisonResult: - scenario_name: str - diffs: tuple[TableDiff, ...] - covered_tables: frozenset[str] - census_tables: frozenset[str] - extra_checks: dict[str, bool] = field(default_factory=dict) - - @property - def diverging_tables(self) -> tuple[TableDiff, ...]: - return tuple(diff for diff in self.diffs if not diff.is_empty) - - @property - def all_passed(self) -> bool: - return not self.diverging_tables and all(self.extra_checks.values()) - - def format_report(self) -> str: - lines = [f"scenario: {self.scenario_name}"] - uncovered = self.census_tables - self.covered_tables - _ALLOWLISTED_DERIVED_TABLES - lines.append(f"census: {len(self.census_tables)} tables, {len(uncovered)} uncovered") - if uncovered: - lines.append(f" UNCOVERED (neither diffed nor allowlisted): {sorted(uncovered)}") - for diff in self.diffs: - status = "OK" if diff.is_empty else "DIVERGED" - lines.append(f" {diff.table}: {status}") - if not diff.is_empty: - lines.append(f" only in A: {diff.only_in_a[:5]}{'...' if len(diff.only_in_a) > 5 else ''}") - lines.append(f" only in B: {diff.only_in_b[:5]}{'...' if len(diff.only_in_b) > 5 else ''}") - for name, passed in self.extra_checks.items(): - lines.append(f" {name}: {'OK' if passed else 'FAILED'}") - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Shared seed corpus -# --------------------------------------------------------------------------- - -_DEMO_STRATA: tuple[Stratum, ...] = ( - Stratum("rebuild-safety/codex-session", Provider.CODEX, count=4, total_bytes=4 * 4_000), - Stratum("rebuild-safety/claude-code-session", Provider.CLAUDE_CODE, count=4, total_bytes=4 * 3_000), -) - -_CONTENT_BEARING_FIXTURES: tuple[tuple[Provider, str], ...] = ( - (Provider.CODEX, "tests/data/codex_event_stream/tool_call_stream.jsonl"), - (Provider.CLAUDE_CODE, "tests/fixtures/claude-code/claude-normalization-main.jsonl"), -) - -_LINEAGE_WITNESS_PAYLOAD = b"\n".join( - ( - b'{"type":"session_meta","payload":{"id":"rebuild-safety-child","timestamp":"2025-01-15T14:00:00Z","cwd":"/repo/polylogue","forked_from_id":"rebuild-safety-parent"}}', - b'{"type":"response_item","payload":{"type":"message","id":"rebuild-safety-user","role":"user","content":[{"type":"input_text","text":"continue rebuild safety"}]}}', - b'{"type":"response_item","payload":{"type":"function_call","id":"rebuild-safety-command","call_id":"rebuild-safety-command","name":"Bash","arguments":"{\\"command\\":\\"printf rebuild-safety-trigram\\"}"}}', - ) -) - - -def _seed_demo_corpus(archive_root: Path) -> list[str]: - """Seed parser witnesses that exercise content-bearing derived relations.""" - raw_ids: list[str] = [] - for stratum in _DEMO_STRATA: - raw_ids.extend(build_stratum_sample_corpus(archive_root, stratum, sample_n=stratum.count)) - repo_root = Path(__file__).resolve().parents[1] - with ArchiveStore.open_existing(archive_root, read_only=False) as archive: - for provider, relative_path in _CONTENT_BEARING_FIXTURES: - fixture_path = repo_root / relative_path - raw_ids.append( - archive.write_raw_payload( - provider=provider, - payload=fixture_path.read_bytes(), - source_path=f"rebuild-safety/{relative_path}", - acquired_at_ms=len(raw_ids) + 1, - ) - ) - raw_ids.append( - archive.write_raw_payload( - provider=Provider.CODEX, - payload=_LINEAGE_WITNESS_PAYLOAD, - source_path="rebuild-safety/codex-lineage-witness.jsonl", - acquired_at_ms=len(raw_ids) + 1, - ) - ) - return raw_ids - - -def _write_attachment_witness(archive_root: Path) -> tuple[str, ...]: - """Write a browser-acquired attachment through the production writer. - - Rebuild replay deliberately consumes source raw bytes without re-running a - browser's authenticated attachment acquisition. The witness therefore uses - the same preacquired-blob handoff as the live browser-ingest path, after - each compared replay has completed. - """ - fixture_path = Path(__file__).resolve().parents[1] / "tests/fixtures/chatgpt/native-browser-capture-v1.json" - roundtrip = parse_payload_roundtrip("browser-capture", fixture_path.read_bytes(), "rebuild-safety-attachment") - parsed = roundtrip.parsed.model_copy( - update={ - "attachments": tuple(attachment for attachment in roundtrip.parsed.attachments if attachment.inline_bytes) - } - ) - blob_store = BlobStore(archive_root / "blob") - preacquired: dict[int, tuple[bytes | None, int, str]] = {} - blob_hashes: list[str] = [] - for attachment in parsed.attachments: - if attachment.inline_bytes is None: - continue - blob_hash, byte_count = blob_store.write_from_bytes(attachment.inline_bytes) - blob_hashes.append(blob_hash) - preacquired[id(attachment)] = (bytes.fromhex(blob_hash), byte_count, "acquired") - conn = sqlite3.connect(archive_root / "index.db") - try: - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - write_parsed_session_to_archive( - conn, - parsed, - content_hash=session_content_hash(parsed), - preacquired_attachment_blobs=preacquired, - ) - finally: - conn.close() - return tuple(blob_hashes) - - -def _discard_attachment_witness_blobs(archive_root: Path, blob_hashes: tuple[str, ...]) -> None: - """Discard unowned witness bytes in this scenario's private temporary archive. - - This is test-fixture cleanup after the derived index carrying the only - attachment ownership rows has been moved aside. It is not a BlobStore or - production GC capability: the enclosing ``TemporaryDirectory`` owns the - entire archive and its contents cannot outlive this scenario. - """ - blob_store = BlobStore(archive_root / "blob") - for blob_hash in blob_hashes: - path = blob_store.blob_path(blob_hash) - if not path.exists(): - raise RuntimeError(f"attachment witness blob disappeared before cleanup: {blob_hash}") - path.unlink() - - -def _seed_user_assertion(archive_root: Path) -> str: - """Write one durable user.db assertion the rebuild must never touch.""" - user_db = archive_root / "user.db" - conn = sqlite3.connect(user_db) - try: - assertion_id = "rebuild-safety-canary" - conn.execute( - """ - INSERT INTO assertions ( - assertion_id, target_ref, kind, body_text, created_at_ms, updated_at_ms - ) VALUES (?, 'workspace:rebuild-safety', 'note', 'rebuild-safety canary assertion', 1, 1) - """, - (assertion_id,), - ) - conn.commit() - return assertion_id - finally: - conn.close() - - -def _user_tier_snapshot(archive_root: Path) -> tuple[object, ...]: - """Capture every canonical durable-user relation and column before reset.""" - return capture_canonical_snapshot(archive_root).user_state - - -# --------------------------------------------------------------------------- -# Census + dump + diff engine -# --------------------------------------------------------------------------- - - -def _index_table_names() -> frozenset[str]: - """Every table a fresh ``index.db`` DDL init declares (the census).""" - with TemporaryDirectory() as tmp: - scratch = Path(tmp) / "census-index.db" - conn = sqlite3.connect(scratch) - try: - initialize_archive_tier(conn, ArchiveTier.INDEX) - return frozenset( - row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() - ) - finally: - conn.close() - - -def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: - return [str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()] - - -def _dump_table(conn: sqlite3.Connection, table: str) -> tuple[tuple[object, ...], ...]: - if table == "messages_fts_identity": - # ``rowid`` is a local SQLite allocation artifact: the same block can - # receive a different rowid when live incremental writes arrive in a - # different order from raw replay. Compare every durable identity - # field exactly, and validate the rowid-to-block binding separately. - rows = conn.execute( - """ - SELECT block_id, source_hash, recipe_id - FROM messages_fts_identity - ORDER BY block_id, source_hash, recipe_id - """ - ).fetchall() - return tuple(tuple(row) for row in rows) - volatile_columns = _SESSION_PROFILE_VOLATILE_COLUMNS if table == "session_profiles" else _VOLATILE_COLUMNS - columns = [c for c in _table_columns(conn, table) if c not in volatile_columns] - if not columns: - return () - quoted = ", ".join(f'"{c}"' for c in columns) - rows = conn.execute(f"SELECT {quoted} FROM {table} ORDER BY {quoted}").fetchall() - return tuple(tuple(row) for row in rows) - - -def _diff_table( - table: str, dump_a: tuple[tuple[object, ...], ...], dump_b: tuple[tuple[object, ...], ...] -) -> TableDiff: - set_a, set_b = set(dump_a), set(dump_b) - return TableDiff( - table=table, only_in_a=tuple(sorted(set_a - set_b, key=str)), only_in_b=tuple(sorted(set_b - set_a, key=str)) - ) - - -def _fts_probe_term(conn: sqlite3.Connection, table: str) -> str | None: - vocab_name = f"rebuild_safety_{table}_vocab" - conn.execute(f"CREATE VIRTUAL TABLE temp.{vocab_name} USING fts5vocab(main, {table}, row)") - try: - row = conn.execute(f"SELECT term FROM temp.{vocab_name} WHERE doc > 0 ORDER BY term LIMIT 1").fetchone() - return str(row[0]) if row is not None else None - finally: - conn.execute(f"DROP TABLE temp.{vocab_name}") - - -def _logical_fts_rows(conn: sqlite3.Connection, table: str) -> tuple[tuple[object, ...], ...]: - if table == "messages_fts": - term = _fts_probe_term(conn, table) - if term is None: - return () - rows = conn.execute( - """ - SELECT blocks.block_id - FROM messages_fts - JOIN blocks ON blocks.rowid = messages_fts.rowid - WHERE messages_fts MATCH ? - ORDER BY blocks.block_id - """, - (term,), - ).fetchall() - elif table == "blocks_command_trigram": - # FTS5's vocabulary extension does not expose a usable probe for this - # partial external-content trigram index. Its indexed contract is the - # tool-detail text, so derive a real LIKE probe from a tool-use block. - row = conn.execute( - """ - SELECT tool_detail_text - FROM blocks - WHERE block_type = 'tool_use' AND tool_detail_text != '' - ORDER BY block_id - LIMIT 1 - """ - ).fetchone() - if row is None: - return () - term = str(row[0]) - rows = conn.execute( - """ - SELECT block_id - FROM blocks - WHERE rowid IN ( - SELECT rowid - FROM blocks_command_trigram - WHERE tool_detail_text LIKE ? - ) - ORDER BY block_id - """, - (f"%{term}%",), - ).fetchall() - elif table == "session_work_events_fts": - term = _fts_probe_term(conn, table) - if term is None: - return () - rows = conn.execute( - """ - SELECT session_work_events.event_id - FROM session_work_events_fts - JOIN session_work_events ON session_work_events.event_id = session_work_events_fts.event_id - WHERE session_work_events_fts MATCH ? - ORDER BY session_work_events.event_id - """, - (term,), - ).fetchall() - else: - raise ValueError(f"unsupported logical FTS surface: {table}") - return tuple((term, str(row[0])) for row in rows) - - -def _messages_fts_identity_is_consistent(conn: sqlite3.Connection) -> bool: - """Require every ledger row to retain its own database's block-row binding.""" - return int(conn.execute(message_identity_mismatch_sql()).fetchone()[0]) == 0 - - -def _diff_index_databases(index_db_a: Path, index_db_b: Path, *, scenario_name: str) -> RebuildComparisonResult: - census = _index_table_names() - diffable_tables = sorted(census - _ALLOWLISTED_DERIVED_TABLES) - conn_a = sqlite3.connect(index_db_a) - conn_b = sqlite3.connect(index_db_b) - try: - table_diffs = tuple( - _diff_table(table, _dump_table(conn_a, table), _dump_table(conn_b, table)) for table in diffable_tables - ) - logical_fts_rows_a = {table: _logical_fts_rows(conn_a, table) for table in _LOGICAL_FTS_SURFACES} - logical_fts_rows_b = {table: _logical_fts_rows(conn_b, table) for table in _LOGICAL_FTS_SURFACES} - fts_diffs = tuple( - _diff_table(f"{table} logical query", logical_fts_rows_a[table], logical_fts_rows_b[table]) - for table in sorted(_LOGICAL_FTS_SURFACES) - ) - identity_consistent_a = _messages_fts_identity_is_consistent(conn_a) - identity_consistent_b = _messages_fts_identity_is_consistent(conn_b) - finally: - conn_a.close() - conn_b.close() - return RebuildComparisonResult( - scenario_name=scenario_name, - diffs=(*table_diffs, *fts_diffs), - covered_tables=frozenset(diffable_tables), - census_tables=census, - extra_checks={ - "messages_fts_identity_a_is_consistent": identity_consistent_a, - "messages_fts_identity_b_is_consistent": identity_consistent_b, - **{ - f"{table}_logical_query_populated": bool(logical_fts_rows_a[table]) and bool(logical_fts_rows_b[table]) - for table in sorted(_LOGICAL_FTS_SURFACES) - }, - }, - ) - - -@contextmanager -def _archive_root_env(archive_root: Path) -> Iterator[None]: - """Scope ``POLYLOGUE_ARCHIVE_ROOT`` to ``archive_root`` for one call. - - Some replay internals (revision-backfill's owned-inactive-generation - store) resolve the archive root from process config rather than the - request object passed to them. Restores the prior value (present or - absent) on exit so this scenario never leaks its scratch root into a - caller's later config resolution. - """ - prior = os.environ.get("POLYLOGUE_ARCHIVE_ROOT") - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = str(archive_root) - try: - yield - finally: - if prior is None: - os.environ.pop("POLYLOGUE_ARCHIVE_ROOT", None) - else: - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = prior - - -def _full_rebuild(archive_root: Path) -> None: - """Reset index.db and replay the full source.db raw population (full rebuild). - - An unfiltered ``RebuildIndexRequest`` (no ``raw_ids``/``only_missing``) - selects every raw row via ``all_index_rebuild_raw_ids`` and is the only - selection ``validate_rebuild_index_request`` allows to combine with - ``promote=True`` -- a partial selection can never replace the active - index, by the same rule ``polylogue ops maintenance rebuild-index``'s - CLI enforces. - """ - index_db = archive_root / "index.db" - if index_db.exists(): - index_db.unlink() - conn = sqlite3.connect(index_db) - try: - initialize_archive_tier(conn, ArchiveTier.INDEX) - finally: - conn.close() - receipt_path = archive_root.parent / f"{archive_root.name}-schema-inference-gate-receipt.json" - if not receipt_path.exists(): - write_valid_rebuild_receipt(archive_root, receipt_path) - request = RebuildIndexRequest( - archive_root=archive_root, - promote=True, - schema_inference_receipt_path=receipt_path, - ) - # The revision-backfill step inside the replay engine resolves its own - # inactive-generation store from the process-wide Config root, not from - # ``request.archive_root`` directly -- the same env-var scoping - # ``tests.infra.rebuild_cost_model._run_one_rebuild_pass`` uses for the - # identical reason. - with _archive_root_env(archive_root): - rebuild_index_from_source_sync(request) - - -# --------------------------------------------------------------------------- -# rebuild-safety (polylogue-1xc.8) -# --------------------------------------------------------------------------- - - -def run_rebuild_safety() -> RebuildComparisonResult: - """Prove derived-tier reset+rebuild is lossless, idempotent, and user.db-safe.""" - with TemporaryDirectory() as tmp: - archive_root = Path(tmp) / "archive" - initialize_active_archive_root(archive_root) - _raw_ids = _seed_demo_corpus(archive_root) - backfill_historical_revision_evidence(archive_root, ingest_workers=1) - _seed_user_assertion(archive_root) - before_user_tier = _user_tier_snapshot(archive_root) - - first_pass = Path(tmp) / "index-first.db" - _full_rebuild(archive_root) - first_witness_blobs = _write_attachment_witness(archive_root) - (archive_root / "index.db").rename(first_pass) - _discard_attachment_witness_blobs(archive_root, first_witness_blobs) - - second_pass = Path(tmp) / "index-second.db" - _full_rebuild(archive_root) - second_witness_blobs = _write_attachment_witness(archive_root) - after_user_tier = _user_tier_snapshot(archive_root) - (archive_root / "index.db").rename(second_pass) - _discard_attachment_witness_blobs(archive_root, second_witness_blobs) - - result = _diff_index_databases(first_pass, second_pass, scenario_name=REBUILD_SAFETY_SCENARIO_NAME) - extra_checks = { - **result.extra_checks, - "user_db_untouched": before_user_tier == after_user_tier, - } - return RebuildComparisonResult( - scenario_name=result.scenario_name, - diffs=result.diffs, - covered_tables=result.covered_tables, - census_tables=result.census_tables, - extra_checks=extra_checks, - ) - - -# --------------------------------------------------------------------------- -# rebuild-differential (polylogue-hjwr) -# --------------------------------------------------------------------------- - - -def _incremental_ingest_and_converge(archive_root: Path, raw_ids: list[str]) -> None: - """Path B: per-session live write + explicit convergence-stage catch-up. - - Mirrors real daemon operation: a new session lands via - ``write_parsed_session_to_archive`` one at a time (never through the - from-scratch rebuild engine), and derived insight tables - (``session_profiles``/``session_latency_profiles``/``session_work_events``/ - ``session_phases``/threads) are populated afterward by the SAME - convergence stage the live daemon runs - (``daemon.convergence_stages.make_insights_stage``), not a - differently-derived approximation of it. - """ - index_db = archive_root / "index.db" - if index_db.exists(): - index_db.unlink() - conn = sqlite3.connect(index_db) - try: - initialize_archive_tier(conn, ArchiveTier.INDEX) - finally: - conn.close() - - blob_store = BlobStore(archive_root / "blob") - source_db = archive_root / "source.db" - session_ids: list[str] = [] - conn = sqlite3.connect(source_db) - try: - conn.row_factory = sqlite3.Row - for raw_id in raw_ids: - row = conn.execute( - "SELECT origin, blob_hash, capture_mode FROM raw_sessions WHERE raw_id = ?", (raw_id,) - ).fetchone() - if row is None: - continue - blob_hash_hex = bytes(row["blob_hash"]).hex() - raw_bytes = blob_store.read_all(blob_hash_hex) - payload = decode_source_payload(raw_bytes) - detected = detect_provider(payload) - if detected is None: - raise RuntimeError(f"incremental parser could not detect raw payload {raw_id}") - for parsed in parse_payload(detected, payload, raw_id): - session_ids.append(write_session_sync(index_db, parsed, raw_id=raw_id)) - finally: - conn.close() - - insights_stage = make_insights_stage(index_db) - execute_sessions = insights_stage.execute_sessions - if execute_sessions is None: - raise RuntimeError("insights convergence stage does not expose session-scoped execution") - if not execute_sessions(session_ids): - raise RuntimeError("insights convergence remained pending") - - -def run_rebuild_differential() -> RebuildComparisonResult: - """Prove full rebuild and incremental-ingest-plus-convergence agree.""" - with TemporaryDirectory() as tmp: - archive_root = Path(tmp) / "archive" - initialize_active_archive_root(archive_root) - raw_ids = _seed_demo_corpus(archive_root) - backfill_historical_revision_evidence(archive_root, ingest_workers=1) - - full_pass = Path(tmp) / "index-full.db" - _full_rebuild(archive_root) - full_witness_blobs = _write_attachment_witness(archive_root) - (archive_root / "index.db").rename(full_pass) - _discard_attachment_witness_blobs(archive_root, full_witness_blobs) - - full_rerun_pass = Path(tmp) / "index-full-rerun.db" - _full_rebuild(archive_root) - rerun_witness_blobs = _write_attachment_witness(archive_root) - (archive_root / "index.db").rename(full_rerun_pass) - _discard_attachment_witness_blobs(archive_root, rerun_witness_blobs) - - determinism = _diff_index_databases(full_pass, full_rerun_pass, scenario_name="rebuild-determinism") - - incremental_pass = Path(tmp) / "index-incremental.db" - _incremental_ingest_and_converge(archive_root, raw_ids) - incremental_witness_blobs = _write_attachment_witness(archive_root) - (archive_root / "index.db").rename(incremental_pass) - _discard_attachment_witness_blobs(archive_root, incremental_witness_blobs) - - differential = _diff_index_databases( - full_pass, incremental_pass, scenario_name=REBUILD_DIFFERENTIAL_SCENARIO_NAME - ) - return RebuildComparisonResult( - scenario_name=REBUILD_DIFFERENTIAL_SCENARIO_NAME, - diffs=differential.diffs, - covered_tables=differential.covered_tables, - census_tables=differential.census_tables, - extra_checks={ - **differential.extra_checks, - "full_rebuild_is_deterministic": determinism.all_passed, - }, - ) - - -__all__ = [ - "REBUILD_DIFFERENTIAL_SCENARIO_NAME", - "REBUILD_SAFETY_SCENARIO_NAME", - "RebuildComparisonResult", - "TableDiff", - "run_rebuild_differential", - "run_rebuild_safety", -] diff --git a/devtools/verification_scenario.py b/devtools/verification_scenario.py index 5d8ac40a73..1667a62f6a 100644 --- a/devtools/verification_scenario.py +++ b/devtools/verification_scenario.py @@ -9,20 +9,12 @@ import subprocess import sys import time -from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Protocol, TextIO from devtools import repo_root as _get_root from devtools.cli_boundary import invoke_polylogue_cli -from devtools.rebuild_safety_scenario import ( - REBUILD_DIFFERENTIAL_SCENARIO_NAME, - REBUILD_SAFETY_SCENARIO_NAME, - RebuildComparisonResult, - run_rebuild_differential, - run_rebuild_safety, -) from devtools.storage_correctness_scenario import ( STORAGE_CORRECTNESS_SCENARIO_NAME, run_storage_correctness, @@ -35,7 +27,6 @@ "archive-smoke", "reader-visual-smoke", STORAGE_CORRECTNESS_SCENARIO_NAME, - REBUILD_SAFETY_SCENARIO_NAME, ) _ARCHIVE_SMOKE_TIER = 0 _READER_VISUAL_SMOKE_PYTEST_ARGS: tuple[str, ...] = ("-m", "pytest", "-q", "tests/visual") @@ -128,77 +119,6 @@ def failed_stages(self) -> tuple[str, ...]: return tuple(name for name, status in self.stage_statuses().items() if status is OutcomeStatus.ERROR) -class RebuildSafetyResult: - """Direct result wrapper for the derived-tier rebuild verification lane.""" - - def __init__(self, *, report_dir: Path | None) -> None: - self.report_dir = report_dir - self.safety, self.safety_error = self._run("rebuild-safety", run_rebuild_safety) - self.differential, self.differential_error = self._run("rebuild-differential", run_rebuild_differential) - self._write_report() - - @staticmethod - def _run( - name: str, runner: Callable[[], RebuildComparisonResult] - ) -> tuple[RebuildComparisonResult | None, str | None]: - try: - return runner(), None - except Exception as exc: - return None, f"{name} failed: {type(exc).__name__}: {exc}" - - @staticmethod - def _report(value: RebuildComparisonResult | None, error: str | None) -> str: - if error is not None: - return error - assert value is not None - return value.format_report() - - def _write_report(self) -> None: - if self.report_dir is None: - return - self.report_dir.mkdir(parents=True, exist_ok=True) - (self.report_dir / "rebuild-safety.txt").write_text( - f"{self._report(self.safety, self.safety_error)}\n\n" - f"{self._report(self.differential, self.differential_error)}\n", - encoding="utf-8", - ) - - @property - def scenario_name(self) -> str: - return REBUILD_SAFETY_SCENARIO_NAME - - @property - def all_passed(self) -> bool: - return ( - self.safety_error is None - and self.differential_error is None - and self.safety is not None - and self.differential is not None - and self.safety.all_passed - and self.differential.all_passed - ) - - def stage_statuses(self) -> dict[str, OutcomeStatus]: - return { - REBUILD_SAFETY_SCENARIO_NAME: OutcomeStatus.OK - if self.safety_error is None and self.safety is not None and self.safety.all_passed - else OutcomeStatus.ERROR, - REBUILD_DIFFERENTIAL_SCENARIO_NAME: OutcomeStatus.OK - if self.differential_error is None and self.differential is not None and self.differential.all_passed - else OutcomeStatus.ERROR, - } - - def failed_stages(self) -> tuple[str, ...]: - return tuple(name for name, status in self.stage_statuses().items() if status is OutcomeStatus.ERROR) - - def extra_payload(self) -> dict[str, object]: - payload: dict[str, object] = { - "safety_report": self._report(self.safety, self.safety_error), - "differential_report": self._report(self.differential, self.differential_error), - } - return payload - - def get_archive_smoke_checks() -> tuple[ArchiveSmokeCheck, ...]: """Return direct CLI checks for the archive-smoke scenario.""" return _ARCHIVE_SMOKE_CHECKS @@ -253,11 +173,6 @@ def list_scenarios(*, as_json: bool) -> int: "command": " ".join((sys.executable, *_READER_VISUAL_SMOKE_PYTEST_ARGS)), }, storage_correctness_scenario_entry(), - { - "name": REBUILD_SAFETY_SCENARIO_NAME, - "kind": "derived-tier-differential", - "checks": [REBUILD_SAFETY_SCENARIO_NAME, REBUILD_DIFFERENTIAL_SCENARIO_NAME], - }, ] payload = {"scenarios": scenarios} if as_json: @@ -271,9 +186,6 @@ def list_scenarios(*, as_json: bool) -> int: if name == "storage-correctness": print(f"{name:<20s} checks: {entry['check_count']}") continue - if name == REBUILD_SAFETY_SCENARIO_NAME: - print(f"{name:<20s} checks: rebuild safety + differential") - continue print(f"{name:<20s} tier-0 checks: {entry['tier_0_check_count']}") return 0 @@ -479,8 +391,6 @@ def main(argv: list[str] | None = None) -> int: result: _ScenarioResult if args.scenario == "storage-correctness": result = run_storage_correctness(report_dir=args.report_dir) - elif args.scenario == REBUILD_SAFETY_SCENARIO_NAME: - result = RebuildSafetyResult(report_dir=args.report_dir) else: result = run_archive_smoke( tier=args.tier, diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index 5fc518d29a..4f6887abda 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -71,24 +71,6 @@ "Preview a maintenance run (dry, resumable) without executing. Read-only.", ), ("run", "_run", "run_command", "Execute maintenance backfill operations."), - ( - "rebuild-index", - "_rebuild_index", - "rebuild_index_command", - "Inspect or execute an authority-safe source-to-index rebuild.", - ), - ( - "rebuild-index-status", - "_rebuild_index_status", - "rebuild_index_status_command", - "Report consolidated raw-replay rebuild status (lease/generation/cursor/delta/recovery). Read-only.", - ), - ( - "reindex-canary", - "_reindex_canary", - "reindex_canary_command", - "Replay an inactive canary, persist observed or reviewed diff evidence, and validate reports.", - ), ( "raw-authority-frontier", "_raw_identity", diff --git a/polylogue/cli/commands/maintenance/_rebuild_index.py b/polylogue/cli/commands/maintenance/_rebuild_index.py deleted file mode 100644 index ec6907e6b8..0000000000 --- a/polylogue/cli/commands/maintenance/_rebuild_index.py +++ /dev/null @@ -1,595 +0,0 @@ -"""``maintenance rebuild-index``: authority-safe source-to-index rebuild.""" - -from __future__ import annotations - -import contextlib -import json -import sqlite3 -from pathlib import Path -from typing import Any, cast -from urllib.error import HTTPError, URLError -from urllib.request import Request, urlopen - -import click - -from polylogue.logging import configure_logging -from polylogue.paths import archive_root -from polylogue.storage.archive_identity import ArchiveLocation - -_BUILTIN_DAEMON_URL = "http://127.0.0.1:8766" - - -def _default_daemon_url() -> str: - """Resolve the default daemon URL through the layered config resolver. - - polylogue-ogn1: this option's default previously read - ``POLYLOGUE_DAEMON_URL`` directly via ``os.environ.get``, bypassing the - 5-layer config precedence chain (site TOML -> user TOML -> env -> CLI) - that every other daemon-URL-consuming surface in this repo goes through - (see ``polylogue.cli.commands.status._default_daemon_url``). A site/user - TOML ``daemon.url`` override was silently ignored here even though it was - honoured everywhere else. - """ - from polylogue.config import load_polylogue_config - - return load_polylogue_config().daemon_url or _BUILTIN_DAEMON_URL - - -def _run_daemon_rebuild( - daemon_url: str, - *, - only_missing: bool, - raw_ids: tuple[str, ...], - max_blob_mb: float | None, - no_promote: bool, - operation_id: str | None, - schema_inference_receipt_path: Path | None, - raw_batch_size: int, - pass_byte_budget_mb: float | None, - pass_deadline_seconds: float | None, -) -> dict[str, object]: - """Execute one rebuild pass through the daemon-owned writer.""" - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - - body_payload: dict[str, object] = { - "only_missing": only_missing, - "raw_ids": list(raw_ids), - "max_blob_mb": max_blob_mb, - "promote": not no_promote, - "operation_id": operation_id, - "schema_inference_receipt_path": ( - str(schema_inference_receipt_path) if schema_inference_receipt_path is not None else None - ), - "raw_batch_size": raw_batch_size, - "pass_byte_budget_mb": pass_byte_budget_mb, - "pass_deadline_seconds": pass_deadline_seconds, - } - body = json.dumps(body_payload).encode("utf-8") - headers = {"Content-Type": "application/json"} - cfg = load_polylogue_config() - if auth_token := resolve_api_auth_token(cfg.api_auth_token, allow_no_auth=cfg.api_allow_no_auth): - headers["Authorization"] = f"Bearer {auth_token}" - request = Request( - f"{daemon_url.rstrip('/')}/api/maintenance/rebuild-index", - data=body, - headers=headers, - method="POST", - ) - try: - with urlopen(request, timeout=600) as response: - payload = json.loads(response.read()) - except HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - raise click.ClickException(f"daemon rebuild rejected by {daemon_url}: HTTP {exc.code}: {detail}") from exc - except (URLError, OSError, ValueError) as exc: - raise click.ClickException(f"could not reach daemon at {daemon_url}: {exc}") from exc - if not isinstance(payload, dict): - raise click.ClickException(f"daemon at {daemon_url} returned an invalid rebuild receipt") - return cast(dict[str, object], payload) - - -def _count_source_raw_sessions(root: Path) -> int: - from polylogue.maintenance.rebuild_index import count_source_raw_sessions - - return count_source_raw_sessions(root) - - -def _missing_index_raw_ids(root: Path) -> list[str]: - from polylogue.maintenance.rebuild_index import missing_index_raw_ids - - return missing_index_raw_ids(root) - - -def _all_index_rebuild_raw_ids(root: Path) -> list[str]: - from polylogue.maintenance.rebuild_index import all_index_rebuild_raw_ids - - return all_index_rebuild_raw_ids(root) - - -def _filter_raw_ids_by_max_blob_size(root: Path, raw_ids: list[str], max_blob_mb: float | None) -> list[str]: - from polylogue.maintenance.rebuild_index import filter_raw_ids_by_max_blob_size - - return filter_raw_ids_by_max_blob_size(root, raw_ids, max_blob_mb) - - -def _rebuild_index_selection_plan( - root: Path, - *, - selected_raw_ids: list[str] | None, - raw_session_count: int, - selected_raw_count: int, - skipped_by_blob_limit_count: int, - only_missing: bool, - explicit_raw_id_count: int, - max_blob_mb: float | None, - limit: int, -) -> dict[str, object]: - source_db = root / "source.db" - index_db = ArchiveLocation.resolve(root).active_index_path - if not source_db.exists(): - return { - "archive_root": str(root), - "status": "empty-source", - "raw_session_count": raw_session_count, - "selected_raw_count": 0, - "skipped_by_blob_limit_count": skipped_by_blob_limit_count, - "only_missing": only_missing, - "raw_id_count": explicit_raw_id_count, - "max_blob_mb": max_blob_mb, - "totals": {}, - "top_rows": [], - } - - selected_clause = "" - params: list[object] = [] - if selected_raw_ids is not None: - if not selected_raw_ids: - return { - "archive_root": str(root), - "status": "ok", - "raw_session_count": raw_session_count, - "selected_raw_count": 0, - "skipped_by_blob_limit_count": skipped_by_blob_limit_count, - "only_missing": only_missing, - "raw_id_count": explicit_raw_id_count, - "max_blob_mb": max_blob_mb, - "replay_order": "blob_hash_asc_raw_id_asc", - "risk_order": "blob_size_desc", - "cost_basis": { - "primary": "source.db raw_sessions.blob_size", - "secondary": [ - "index.db sessions.message_count when already materialized", - "index.db session_events count when already materialized", - ], - }, - "totals": { - "blob_bytes": 0, - "materialized_sessions": 0, - "materialized_messages": 0, - "materialized_session_events": 0, - }, - "top_rows": [], - "top_groups": [], - } - placeholders = ",".join("?" for _ in selected_raw_ids) - selected_clause = f"WHERE r.raw_id IN ({placeholders})" - params.extend(selected_raw_ids) - - index_metrics = ( - """ - COALESCE((SELECT COUNT(*) FROM idx.sessions s WHERE s.raw_id = r.raw_id), 0) AS materialized_sessions, - COALESCE((SELECT SUM(s.message_count) FROM idx.sessions s WHERE s.raw_id = r.raw_id), 0) AS materialized_messages, - COALESCE(( - SELECT COUNT(*) - FROM idx.sessions s - JOIN idx.session_events e ON e.session_id = s.session_id - WHERE s.raw_id = r.raw_id - ), 0) AS materialized_session_events - """ - if index_db.exists() - else """ - 0 AS materialized_sessions, - 0 AS materialized_messages, - 0 AS materialized_session_events - """ - ) - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - if index_db.exists(): - conn.execute("ATTACH DATABASE ? AS idx", (str(index_db),)) - rows = conn.execute( - f""" - SELECT - r.raw_id, - r.origin, - r.native_id, - r.source_path, - r.source_index, - r.blob_size, - r.acquired_at_ms, - {index_metrics} - FROM raw_sessions r - {selected_clause} - ORDER BY r.acquired_at_ms, r.raw_id - """, - params, - ).fetchall() - - top_rows = sorted( - rows, - key=lambda row: (-(int(row[5] or 0)), -(int(row[6] or 0)), str(row[0])), - ) - top_groups_by_key: dict[tuple[str, str], dict[str, Any]] = {} - for row in rows: - origin = str(row[1] or "") - source_or_native = str(row[3] or row[2] or row[0]) - key = (origin, source_or_native) - group = top_groups_by_key.setdefault( - key, - { - "origin": origin, - "native_id": row[2], - "source_path": row[3], - "row_count": 0, - "blob_bytes": 0, - "first_acquired_at_ms": row[6], - "last_acquired_at_ms": row[6], - "materialized_sessions": 0, - "materialized_messages": 0, - "materialized_session_events": 0, - }, - ) - group["row_count"] = int(group["row_count"]) + 1 - group["blob_bytes"] = int(group["blob_bytes"]) + int(row[5] or 0) - acquired_at_ms = row[6] - if group["first_acquired_at_ms"] is None or ( - acquired_at_ms is not None and int(acquired_at_ms) < int(group["first_acquired_at_ms"]) - ): - group["first_acquired_at_ms"] = acquired_at_ms - if group["last_acquired_at_ms"] is None or ( - acquired_at_ms is not None and int(acquired_at_ms) > int(group["last_acquired_at_ms"]) - ): - group["last_acquired_at_ms"] = acquired_at_ms - group["materialized_sessions"] = int(group["materialized_sessions"]) + int(row[7] or 0) - group["materialized_messages"] = int(group["materialized_messages"]) + int(row[8] or 0) - group["materialized_session_events"] = int(group["materialized_session_events"]) + int(row[9] or 0) - top_groups = sorted( - top_groups_by_key.values(), - key=lambda group: (-int(group["blob_bytes"]), -int(group["row_count"]), str(group["origin"])), - ) - totals = { - "blob_bytes": sum(int(row[5] or 0) for row in rows), - "materialized_sessions": sum(int(row[7] or 0) for row in rows), - "materialized_messages": sum(int(row[8] or 0) for row in rows), - "materialized_session_events": sum(int(row[9] or 0) for row in rows), - } - return { - "archive_root": str(root), - "status": "ok", - "raw_session_count": raw_session_count, - "selected_raw_count": selected_raw_count, - "skipped_by_blob_limit_count": skipped_by_blob_limit_count, - "only_missing": only_missing, - "raw_id_count": explicit_raw_id_count, - "max_blob_mb": max_blob_mb, - "replay_order": "blob_hash_asc_raw_id_asc", - "risk_order": "blob_size_desc", - "cost_basis": { - "primary": "source.db raw_sessions.blob_size", - "secondary": [ - "index.db sessions.message_count when already materialized", - "index.db session_events count when already materialized", - ], - }, - "totals": totals, - "top_rows": [ - { - "raw_id": str(row[0]), - "origin": row[1], - "native_id": row[2], - "source_path": row[3], - "source_index": row[4], - "blob_bytes": int(row[5] or 0), - "acquired_at_ms": row[6], - "materialized_sessions": int(row[7] or 0), - "materialized_messages": int(row[8] or 0), - "materialized_session_events": int(row[9] or 0), - } - for row in top_rows[:limit] - ], - "top_groups": top_groups[:limit], - } - - -@click.command("rebuild-index") -@click.option( - "--only-missing", - is_flag=True, - help="Replay only source raw rows that do not yet have index.sessions rows.", -) -@click.option( - "--raw-id", - "raw_ids", - multiple=True, - help="Replay a specific source raw_id. May be supplied multiple times.", -) -@click.option( - "--max-blob-mb", - type=float, - default=None, - help="Bound an explicit replay selection to raw rows at or below this blob size.", -) -@click.option( - "--plan", - "plan_only", - is_flag=True, - help="Print selected raw-row weight totals and top rows without replaying.", -) -@click.option("--plan-limit", type=int, default=10, show_default=True, help="Rows to include in --plan top_rows.") -@click.option( - "--operation-id", - type=str, - default=None, - help="Resume the retained candidate generation for this rebuild operation.", -) -@click.option( - "--schema-inference-receipt", - "schema_inference_receipt_path", - type=click.Path(path_type=Path, dir_okay=False), - default=None, - help="Fresh schema-inference PASS receipt; policy fallback is POLYLOGUE_SCHEMA_INFERENCE_RECEIPT.", -) -@click.option( - "--raw-batch-size", - type=int, - default=500, - show_default=True, - help="Maximum source rows scheduled by this invocation; rerun with --operation-id to continue.", -) -@click.option( - "--pass-byte-budget-mb", - type=float, - default=None, - help="Aggregate raw bytes scheduled per resumable pass; never excludes later source rows.", -) -@click.option( - "--pass-deadline-seconds", - type=float, - default=None, - help="Wall-clock deadline for one resumable pass; expiry defers remaining source rows.", -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format.", -) -@click.option("--no-promote", is_flag=True, help="Leave an exact-ready generation inactive after rebuilding it.") -@click.option( - "--daemon", "use_daemon", is_flag=True, help="Run the bounded rebuild through the live polylogued daemon." -) -@click.option( - "--daemon-url", - default=_default_daemon_url, - show_default="resolved via load_polylogue_config().daemon_url (site/user TOML -> POLYLOGUE_DAEMON_URL -> built-in default)", - help="Daemon HTTP base URL used with --daemon.", -) -@click.option( - "--shard-count", - type=int, - default=1, - show_default=True, - help=( - "polylogue-pzxm: replay this pass's selected raw ids across N owned-inactive " - "generations built in parallel, merged before promotion. 1 keeps the unchanged " - "single-writer path; unsupported with --daemon." - ), -) -@click.option( - "--preflight", - is_flag=True, - help="Read-only: report whether every durable tier matches this package before rebuilding index.db.", -) -def rebuild_index_command( - only_missing: bool, - raw_ids: tuple[str, ...], - max_blob_mb: float | None, - plan_only: bool, - plan_limit: int, - operation_id: str | None, - schema_inference_receipt_path: Path | None, - raw_batch_size: int, - pass_byte_budget_mb: float | None, - pass_deadline_seconds: float | None, - output_format: str, - no_promote: bool, - use_daemon: bool, - daemon_url: str, - shard_count: int, - preflight: bool, -) -> None: - """Inspect or execute an authority-safe source-to-index rebuild. - - Execution expands the requested rows to complete logical revision cohorts; - selection order and batch boundaries never participate in authority. - """ - configure_logging() - if raw_ids and only_missing: - raise click.UsageError("--raw-id cannot be combined with --only-missing") - if (raw_ids or only_missing) and not no_promote and not plan_only: - raise click.UsageError("partial rebuild selections require --no-promote and can never replace the active index") - if max_blob_mb is not None and max_blob_mb <= 0: - raise click.BadParameter("max blob size must be positive", param_hint="--max-blob-mb") - if max_blob_mb is not None and not raw_ids and not only_missing: - raise click.UsageError("--max-blob-mb requires --only-missing or --raw-id") - if plan_limit <= 0: - raise click.BadParameter("plan limit must be positive", param_hint="--plan-limit") - if use_daemon and plan_only: - raise click.UsageError("--daemon executes a rebuild; --plan is always a local read-only preview") - if use_daemon and preflight: - raise click.UsageError("--preflight cannot be combined with --daemon") - if shard_count <= 0: - raise click.BadParameter("shard count must be positive", param_hint="--shard-count") - if use_daemon and shard_count > 1: - raise click.UsageError("--shard-count is a local-execution option; unsupported with --daemon") - if shard_count > 1 and pass_deadline_seconds is not None: - raise click.UsageError("--shard-count does not yet honor --pass-deadline-seconds; use one or the other") - if raw_batch_size <= 0: - raise click.BadParameter("raw batch size must be positive", param_hint="--raw-batch-size") - if pass_byte_budget_mb is not None and pass_byte_budget_mb <= 0: - raise click.BadParameter("pass byte budget must be positive", param_hint="--pass-byte-budget-mb") - if pass_deadline_seconds is not None and pass_deadline_seconds <= 0: - raise click.BadParameter("pass deadline must be positive", param_hint="--pass-deadline-seconds") - if operation_id is not None and (raw_ids or only_missing or max_blob_mb is not None or plan_only): - raise click.UsageError("--operation-id only resumes an unfiltered full-source rebuild") - if operation_id is not None and (pass_byte_budget_mb is not None or pass_deadline_seconds is not None): - raise click.UsageError("resumed rebuild budgets are durable; omit pass budget options with --operation-id") - - root = archive_root() - if preflight: - from polylogue.maintenance.rebuild_index import rebuild_schema_currency_preflight - - payload = rebuild_schema_currency_preflight(root) - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - else: - click.echo(f"Archive root: {root}") - for tier in cast(list[dict[str, object]], payload["tiers"]): - click.echo( - f"{tier['tier']}.db: {tier['actual_user_version']} (package expects " - f"{tier['expected_user_version']}; {tier['status']})" - ) - if payload["status"] != "ready": - raise click.ClickException("rebuild schema currency preflight failed; migrate or deploy before rebuilding") - return - if schema_inference_receipt_path is not None: - # Resolve relative receipt handles against the configured archive - # command context before serializing the daemon request. The shared - # resolver also preserves the refusal for receipt paths inside the - # archive's durable file set. - from polylogue.maintenance.schema_inference_gate import ( - SchemaInferenceGateError, - resolve_schema_inference_receipt_reference, - ) - - try: - schema_inference_receipt_path = resolve_schema_inference_receipt_reference( - root, schema_inference_receipt_path - ) - except SchemaInferenceGateError as exc: - raise click.ClickException(str(exc)) from exc - if use_daemon: - payload = _run_daemon_rebuild( - daemon_url, - only_missing=only_missing, - raw_ids=raw_ids, - max_blob_mb=max_blob_mb, - no_promote=no_promote, - operation_id=operation_id, - schema_inference_receipt_path=schema_inference_receipt_path, - raw_batch_size=raw_batch_size, - pass_byte_budget_mb=pass_byte_budget_mb, - pass_deadline_seconds=pass_deadline_seconds, - ) - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - return - click.echo(f"Archive root: {payload.get('archive_root', root)}") - click.echo(f"Classified: {int(cast(Any, payload['classified_full_count'])):,} full revision(s)") - click.echo(f"Replayed: {int(cast(Any, payload['replayed_logical_source_count'])):,} logical source(s)") - click.echo(f"Quarantined: {int(cast(Any, payload['quarantined_raw_count'])):,} raw row(s)") - return - if plan_only: - raw_count = _count_source_raw_sessions(root) - selected_raw_ids = ( - list(dict.fromkeys(raw_ids)) - if raw_ids - else _missing_index_raw_ids(root) - if only_missing - else _all_index_rebuild_raw_ids(root) - ) - unfiltered_selected_raw_count = len(selected_raw_ids) - selected_raw_ids = _filter_raw_ids_by_max_blob_size(root, selected_raw_ids, max_blob_mb) - selected_raw_count = len(selected_raw_ids) - skipped_by_blob_limit_count = unfiltered_selected_raw_count - selected_raw_count - payload = _rebuild_index_selection_plan( - root, - selected_raw_ids=selected_raw_ids, - raw_session_count=raw_count, - selected_raw_count=selected_raw_count, - skipped_by_blob_limit_count=skipped_by_blob_limit_count, - only_missing=only_missing, - explicit_raw_id_count=len(raw_ids), - max_blob_mb=max_blob_mb, - limit=plan_limit, - ) - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - return - click.echo(f"Archive root: {root}") - click.echo(f"Raw rows: {raw_count:,}") - click.echo(f"Selected: {selected_raw_count:,} raw row(s)") - if skipped_by_blob_limit_count: - click.echo(f"Blob limit: skipped {skipped_by_blob_limit_count:,} raw row(s)") - totals = payload["totals"] if isinstance(payload["totals"], dict) else {} - click.echo(f"Blob bytes: {int(totals.get('blob_bytes', 0)):,}") - click.echo(f"Messages: {int(totals.get('materialized_messages', 0)):,} already materialized") - click.echo(f"Events: {int(totals.get('materialized_session_events', 0)):,} already materialized") - top_rows = payload["top_rows"] if isinstance(payload["top_rows"], list) else [] - if top_rows: - click.echo("Top rows by blob size:") - for row in top_rows: - if isinstance(row, dict): - click.echo( - f" {row['raw_id']} {row['origin']} blob={int(row['blob_bytes']):,} " - f"messages={int(row['materialized_messages']):,} events={int(row['materialized_session_events']):,}" - ) - raw_top_groups: object = payload.get("top_groups") - top_groups = raw_top_groups if isinstance(raw_top_groups, list) else [] - if top_groups: - click.echo("Top groups by blob size:") - for group in top_groups: - if isinstance(group, dict): - click.echo( - f" {group['origin']} rows={int(group['row_count']):,} " - f"blob={int(group['blob_bytes']):,} source={group['source_path']}" - ) - return - from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - RebuildSchemaCurrencyError, - rebuild_index_from_source_sync, - ) - - try: - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - only_missing=only_missing, - raw_ids=raw_ids, - max_blob_mb=max_blob_mb, - promote=not no_promote, - operation_id=operation_id, - schema_inference_receipt_path=schema_inference_receipt_path, - raw_batch_size=raw_batch_size, - pass_byte_budget_mb=pass_byte_budget_mb, - pass_deadline_seconds=pass_deadline_seconds, - shard_count=shard_count, - ) - ) - except (RebuildSchemaCurrencyError, RuntimeError, ValueError) as exc: - raise click.ClickException(str(exc)) from exc - payload = receipt.to_dict() - result = payload - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - return - click.echo(f"Archive root: {root}") - if receipt.status == "empty-source": - click.echo("No source.db raw_sessions rows found.") - return - click.echo(f"Classified: {int(cast(Any, result['classified_full_count'])):,} full revision(s)") - click.echo(f"Replayed: {int(cast(Any, result['replayed_logical_source_count'])):,} logical source(s)") - click.echo(f"Quarantined: {int(cast(Any, result['quarantined_raw_count'])):,} raw row(s)") diff --git a/polylogue/cli/commands/maintenance/_rebuild_index_status.py b/polylogue/cli/commands/maintenance/_rebuild_index_status.py deleted file mode 100644 index 025fc17edf..0000000000 --- a/polylogue/cli/commands/maintenance/_rebuild_index_status.py +++ /dev/null @@ -1,118 +0,0 @@ -"""``maintenance rebuild-index-status``: consolidated raw-replay rebuild status. - -polylogue-b5l.1 AC5: one command reports lease ownership, the active -generation, the resumable transaction's cursor/delta, and explicit -stale-lock/failed-transaction recovery guidance -- see -``polylogue.maintenance.rebuild_index.rebuild_status`` for the assembled -payload this command renders. Entirely read-only. -""" - -from __future__ import annotations - -import json - -import click - -from polylogue.logging import configure_logging -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.paths import archive_root - - -@click.command("rebuild-index-status") -@click.option( - "--operation-id", - "operation_id", - type=str, - default=None, - help=( - "Rebuild transaction to report. Omit to resolve the daemon's own well-known " - "bulk-rebuild operation id (the ops reset --index && polylogued run case)." - ), -) -@click.option( - "--no-daemon-fallback", - "no_daemon_fallback", - is_flag=True, - help="Do not fall back to the daemon's well-known bulk-rebuild operation id when --operation-id is omitted.", -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format.", -) -def rebuild_index_status_command( - operation_id: str | None, - no_daemon_fallback: bool, - output_format: str, -) -> None: - """Report consolidated raw-replay rebuild status. Read-only.""" - if operation_id is not None: - try: - operation_id = validate_operation_id(operation_id) - except ValueError as exc: - raise click.BadParameter(str(exc), param_hint="--operation-id") from exc - - from polylogue.maintenance.rebuild_index import rebuild_status - - configure_logging() - root = archive_root() - status = rebuild_status(root, operation_id=operation_id, include_daemon_bulk_rebuild=not no_daemon_fallback) - - if output_format == "json": - click.echo(json.dumps(status, indent=2, sort_keys=True)) - return - - click.echo(f"Archive root: {status['archive_root']}") - lease = status["lease"] - assert isinstance(lease, dict) - click.echo( - f"Lease: held={lease['held']} holder_pid={lease['holder_pid']} " - f"holder_host={lease['holder_host']} holder_alive={lease['holder_alive']} stale={lease['stale']}" - ) - generation = status["generation"] - if isinstance(generation, dict): - click.echo( - f"Generation: id={generation['generation_id']} state={generation['state']} " - f"created_at_ms={generation['created_at_ms']}" - ) - else: - click.echo("Generation: none") - click.echo(f"Schema: user_version={status['schema_version']}") - click.echo(f"Operation id: {status['operation_id']}") - transaction = status["transaction"] - if isinstance(transaction, dict): - click.echo( - f"Transaction: status={transaction['status']} " - f"processed_raw_count={transaction['processed_raw_count']:,} " - f"processed_blob_bytes={transaction['processed_blob_bytes']:,} " - f"last_raw_id={transaction['last_raw_id']} updated_at_ms={transaction['updated_at_ms']} " - f"heartbeat_at_ms={transaction.get('heartbeat_at_ms')}" - ) - else: - click.echo("Transaction: none") - operation = status["operation"] - assert isinstance(operation, dict) - owner = operation["owner"] - assert isinstance(owner, dict) - click.echo( - f"Operation: owner={owner.get('generation_owner_id')} pid={owner.get('pid')} " - f"host={owner.get('host')} cursor={operation.get('cursor')} " - f"recovery_state={operation.get('recovery_state')}" - ) - delta = status["delta"] - if isinstance(delta, dict): - click.echo(f"Delta: source_snapshot_matches={delta['source_snapshot_matches']}") - recovery = status["recovery"] - assert isinstance(recovery, list) - if recovery: - click.echo("Recovery:") - for message in recovery: - click.echo(f" - {message}") - else: - click.echo("Recovery: none") - - -__all__ = ["rebuild_index_status_command"] diff --git a/polylogue/cli/commands/maintenance/_reindex_canary.py b/polylogue/cli/commands/maintenance/_reindex_canary.py deleted file mode 100644 index 1721be0042..0000000000 --- a/polylogue/cli/commands/maintenance/_reindex_canary.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Run a bounded semantic reindex canary through the existing rebuild service.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import click - - -@click.command("reindex-canary") -@click.option( - "--archive-root", - type=click.Path(path_type=Path, exists=True, file_okay=False, readable=True), - required=True, - help="Explicit isolated archive containing durable source.db and the active index candidate.", -) -@click.option( - "--review-manifest", - type=click.Path(path_type=Path, exists=True, dir_okay=False, readable=True), - default=None, - help="JSON manifest containing one explicit review for every observed difference.", -) -@click.option( - "--input-index", - "--input", - "input_index", - type=click.Path(path_type=Path, exists=True, dir_okay=False, readable=True), - default=None, - help="Explicit active index.db to compare and select from. Defaults to the configured active index.", -) -@click.option( - "--sessions-per-origin", - "--sample", - "sessions_per_origin", - type=int, - default=100, - show_default=True, - help="Newest replayable sessions selected automatically for each origin.", -) -@click.option( - "--pathology-session-id", - multiple=True, - help="Explicit pathology session_id to include. May be supplied multiple times.", -) -@click.option( - "--sample-session-id", - multiple=True, - help="Explicit sample session_id to include. May be supplied multiple times.", -) -@click.option( - "--report", - "report_path", - type=click.Path(path_type=Path, dir_okay=False), - required=True, - help="Canary report destination, or existing report with --consume-report.", -) -@click.option( - "--consume-report", - is_flag=True, - help="Validate and approve evidence under archive/rebuild ownership. This never authorizes promotion.", -) -@click.option( - "--schema-inference-receipt", - "schema_inference_receipt_path", - type=click.Path(path_type=Path, exists=True, dir_okay=False, readable=True), - required=False, - help="Explicit fresh schema-inference PASS receipt consumed by the canary rebuild.", -) -@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) -@click.option( - "--no-promote", - is_flag=True, - help="Required safety gate: leave the rebuilt candidate inactive for comparison.", -) -def reindex_canary_command( - archive_root: Path, - review_manifest: Path | None, - input_index: Path | None, - sessions_per_origin: int, - pathology_session_id: tuple[str, ...], - sample_session_id: tuple[str, ...], - report_path: Path, - consume_report: bool, - schema_inference_receipt_path: Path | None, - output_format: str, - no_promote: bool, -) -> None: - """Replay representative raw inputs into an inactive generation and compare it.""" - - if not no_promote: - raise click.UsageError("reindex-canary requires --no-promote") - if sessions_per_origin <= 0: - raise click.BadParameter("sample size must be positive", param_hint="--sessions-per-origin") - - from polylogue.maintenance.reindex_canary import ( - CanaryRunResult, - UnclassifiedCanaryDiffError, - load_canary_report, - load_canary_review_manifest, - run_reindex_canary, - write_canary_report, - ) - - if consume_report: - if review_manifest is not None or input_index is not None or pathology_session_id or sample_session_id: - raise click.UsageError("--consume-report cannot be combined with rebuild selection options") - if not report_path.is_file(): - raise click.BadParameter("report file does not exist", param_hint="--report") - try: - from polylogue.daemon.bulk_rebuild import consume_daemon_canary_report - - payload = consume_daemon_canary_report(archive_root=archive_root, report_path=report_path) - except UnclassifiedCanaryDiffError as exc: - raise click.ClickException(str(exc)) from exc - except (OSError, RuntimeError, ValueError) as exc: - raise click.ClickException(str(exc)) from exc - result_payload = {**payload, "decision": "evidence-approved", "promotion_authorized": False} - if output_format == "json": - click.echo(json.dumps(result_payload, indent=2, sort_keys=True)) - return - click.echo("Decision: evidence approved; promotion is not authorized") - click.echo(f"Report: {report_path}") - return - - result: CanaryRunResult | None = None - if schema_inference_receipt_path is None: - raise click.UsageError("reindex-canary rebuild requires --schema-inference-receipt") - try: - result = run_reindex_canary( - archive_root, - input_index=input_index, - sessions_per_origin=sessions_per_origin, - pathology_session_ids=pathology_session_id, - sample_session_ids=sample_session_id, - no_promote=no_promote, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - reviews = load_canary_review_manifest(review_manifest) if review_manifest is not None else () - durable = write_canary_report( - report_path, - selection=result.selection, - comparison=result.comparison, - rebuild_receipt=result.rebuild_receipt, - reviews=reviews, - allow_unreviewed=review_manifest is None, - ) - if durable.review_status != "reviewed": - if output_format == "json": - click.echo(json.dumps(durable.to_dict(), indent=2, sort_keys=True)) - else: - click.echo(f"Selected: {len(result.selection.selected_raw_ids):,} raw row(s)") - click.echo(f"Compared: {len(result.comparison.differences):,} difference(s)") - click.echo("Classified: False (unreviewed discovery report)") - click.echo(f"Report: {report_path}") - raise click.ClickException( - "canary differences were persisted unreviewed; add --review-manifest before consuming" - ) - load_canary_report(report_path, archive_root=archive_root) - except UnclassifiedCanaryDiffError as exc: - raise click.ClickException(str(exc)) from exc - except (OSError, RuntimeError, ValueError) as exc: - raise click.ClickException(str(exc)) from exc - - if result is None: - raise click.ClickException("reindex canary produced no run result") - payload = durable.to_dict() - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - return - click.echo(f"Selected: {len(result.selection.selected_raw_ids):,} raw row(s)") - click.echo(f"Compared: {len(result.comparison.differences):,} difference(s)") - click.echo(f"Classified: {durable.unclassified_count == 0}") - click.echo(f"Report: {report_path}") - - -__all__ = ["reindex_canary_command"] diff --git a/polylogue/cli/commands/paths.py b/polylogue/cli/commands/paths.py index 08b7b3ce37..7c69a0ac84 100644 --- a/polylogue/cli/commands/paths.py +++ b/polylogue/cli/commands/paths.py @@ -12,7 +12,6 @@ from polylogue.storage import archive_layout from polylogue.storage.archive_readiness import ( - active_rebuild_index_attempts, raw_materialization_readiness_snapshot, raw_materialization_ready, ) @@ -65,22 +64,14 @@ def paths_command(output_format: str) -> None: archive_schema_ready = all( tier_versions[name]["version_status"] == "ok" for name in ("source", "index", "embeddings", "ops", "user") ) - active_rebuild_attempts = active_rebuild_index_attempts(ops_db) raw_materialization_readiness = _raw_materialization_readiness(active_archive) archive_materialization_ready = ( source_db.exists() and db.exists() and archive_schema_ready - and not active_rebuild_attempts and raw_materialization_ready(raw_materialization_readiness) ) - archive_ready = ( - source_db.exists() - and db.exists() - and archive_schema_ready - and not active_rebuild_attempts - and archive_materialization_ready - ) + archive_ready = source_db.exists() and db.exists() and archive_schema_ready and archive_materialization_ready final_shape_ready = not missing_tiers missing_backup_required = [tier for tier in archive_layout.BACKUP_REQUIRED_TIERS if tier in missing_tiers] layout_blockers = archive_layout.archive_layout_blockers( @@ -123,7 +114,6 @@ def paths_command(output_format: str) -> None: "archive_ready": archive_ready, "archive_materialization_ready": archive_materialization_ready, "raw_materialization_readiness": raw_materialization_readiness, - "active_rebuild_index_attempts": active_rebuild_attempts, "final_shape_ready": final_shape_ready, "archive_schema_ready": archive_schema_ready, "archive_layout_ready": archive_layout_ready, @@ -168,10 +158,7 @@ def paths_command(output_format: str) -> None: _print_line("Archive layout", "ready" if archive_layout_ready else "not ready", extra=layout_status_extra) schema_extra = "ready" if archive_schema_ready else _schema_blocker_text(tier_versions) _print_line("Archive schema", "ready" if archive_schema_ready else "not ready", extra=schema_extra) - if active_rebuild_attempts: - _print_line("Archive materialization", "rebuilding", extra=f"attempts={len(active_rebuild_attempts)}") - else: - _print_line("Archive materialization", "ready" if archive_materialization_ready else "not ready") + _print_line("Archive materialization", "ready" if archive_materialization_ready else "not ready") _print_line("Source DB", str(source_db), extra=_tier_extra("source", source_db, tier_versions)) _print_line("Index DB", str(db), extra=_tier_extra("index", db, tier_versions)) _print_line( diff --git a/polylogue/cli/commands/reset.py b/polylogue/cli/commands/reset.py index 01bae2c48f..9fcc69dd78 100644 --- a/polylogue/cli/commands/reset.py +++ b/polylogue/cli/commands/reset.py @@ -85,7 +85,7 @@ def _archive_database_targets( if (root / ".index-active-pointer").exists() and any(filename == "index.db" for _name, filename in databases): raise click.ClickException( "reset --database is unsafe for a managed active generation; " - "use `polylogue ops maintenance rebuild-index` to create and promote a replacement" + "the pointer-managed index.db must not be deleted in place" ) targets: list[tuple[str, Path]] = [] for name, filename in databases: @@ -112,7 +112,7 @@ def _archive_index_targets() -> list[tuple[str, Path]]: if (root / ".index-active-pointer").exists(): raise click.ClickException( "reset --index is unsafe for a managed active generation; " - "use `polylogue ops maintenance rebuild-index` to create and promote a replacement" + "the pointer-managed index.db must not be deleted in place" ) path = _index_db_path() targets: list[tuple[str, Path]] = [] @@ -483,14 +483,13 @@ def reset_command( targets.extend(_archive_database_targets(include_source_db=include_source_db, include_user_db=include_user_db)) if not include_source_db and _source_db_present(): env.ui.console.print( - "Preserving source.db (durable acquired evidence). Rebuild index.db from preserved source evidence " - "with `polylogue ops maintenance rebuild-index`. Pass --include-source-db to delete source.db too." + "Preserving source.db (durable acquired evidence). Rebuild index.db from it with `polylogued run`; " + "ordinary convergence replays source.db into index.db. Pass --include-source-db to delete source.db too." ) if not include_user_db and _user_db_present(): env.ui.console.print( "Preserving user.db (irreplaceable: tags, annotations, marks, saved views, " - "notes). Rebuild index.db from preserved source evidence with " - "`polylogue ops maintenance rebuild-index`. " + "notes). Rebuild index.db from preserved source evidence with `polylogued run`. " "Pass --include-user-db to delete user.db too." ) if blob: @@ -552,7 +551,7 @@ def reset_command( env.ui.console.print(f"\nReset complete: {deleted} item(s) deleted.") if index or database: - env.ui.console.print("Next: run `polylogue ops maintenance rebuild-index` to replay source.db into index.db.") + env.ui.console.print("Next: run `polylogued run`; ordinary convergence replays source.db into index.db.") def _dedupe_targets(targets: list[tuple[str, Path]]) -> list[tuple[str, Path]]: diff --git a/polylogue/cli/commands/status_diagnostics.py b/polylogue/cli/commands/status_diagnostics.py index 87ce6535c6..1e3ab3a298 100644 --- a/polylogue/cli/commands/status_diagnostics.py +++ b/polylogue/cli/commands/status_diagnostics.py @@ -193,7 +193,7 @@ def _probe_schema(db: Path) -> StatusDiagnostic | None: kind="schema_mismatch", headline=f"Schema version {current_version} is not runtime {expected_version}.", detail=detail, - next_action="polylogue ops reset --index && polylogue ops maintenance rebuild-index", + next_action="polylogue ops reset --index && polylogued run", ) return None diff --git a/polylogue/cli/convergence_feedback.py b/polylogue/cli/convergence_feedback.py index 709176cd92..324d722d05 100644 --- a/polylogue/cli/convergence_feedback.py +++ b/polylogue/cli/convergence_feedback.py @@ -22,16 +22,11 @@ def convergence_warning_line(active_archive: Path | None = None) -> str | None: try: from polylogue.paths import archive_root from polylogue.storage.archive_readiness import ( - active_rebuild_index_attempts, raw_materialization_readiness_snapshot, raw_materialization_ready, ) root = active_archive or archive_root() - attempts = active_rebuild_index_attempts(root / "ops.db") - if attempts: - return _active_rebuild_warning(attempts) - raw_readiness = raw_materialization_readiness_snapshot(root) if raw_materialization_ready(raw_readiness): return None @@ -41,16 +36,6 @@ def convergence_warning_line(active_archive: Path | None = None) -> str | None: return "Archive convergence state could not be determined; results may be partial." -def _active_rebuild_warning(attempts: list[dict[str, object]]) -> str: - count = len(attempts) - materialized = sum(_safe_int(attempt.get("materialized_count")) for attempt in attempts) - parsed = sum(_safe_int(attempt.get("parsed_raw_count")) for attempt in attempts) - progress = "" - if materialized or parsed: - progress = f" ({materialized:,} sessions materialized from {parsed:,} parsed raw rows)" - return f"Archive is converging: {count:,} index rebuild attempt(s) active{progress}; results may be partial." - - def _raw_materialization_warning(readiness: dict[str, object]) -> str | None: if not readiness.get("available", False): return None diff --git a/polylogue/daemon/api_auth.py b/polylogue/daemon/api_auth.py index 2a3abd27f5..409f22063c 100644 --- a/polylogue/daemon/api_auth.py +++ b/polylogue/daemon/api_auth.py @@ -153,8 +153,8 @@ def token_show(rotate: bool, output_format: str | None) -> None: Use this value for ``Authorization: Bearer `` when calling the daemon API from a script, or when configuring ``daemon.api.auth_token`` / - ``POLYLOGUE_API_AUTH_TOKEN`` for a client (``polylogue ops rebuild-index`` - and friends already read this from config automatically). + ``POLYLOGUE_API_AUTH_TOKEN`` for a client (the CLI's daemon-backed verbs + already read this from config automatically). """ token = load_or_mint_api_auth_token(rotate=rotate) if output_format == "json": diff --git a/polylogue/daemon/bulk_rebuild.py b/polylogue/daemon/bulk_rebuild.py deleted file mode 100644 index cf6223fc74..0000000000 --- a/polylogue/daemon/bulk_rebuild.py +++ /dev/null @@ -1,764 +0,0 @@ -"""Daemon-internal automagic bulk-scale index rebuild routing. - -polylogue-m6tp phase (c) / polylogue-gd6v. The daemon's trickle -raw-materialization conveyor (``_periodic_raw_materialization_convergence``, -``polylogue/daemon/cli.py``) is sized for steady-state drift; a bulk-scale -backlog (#3145's threshold) turns it into a weeks-scale grind. This module -lets the daemon itself route a bulk-scale backlog into a resumable, -transactional, blue-green generation build -- reusing the SAME engine the -offline ``polylogue ops maintenance rebuild-index`` CLI command drives -(``polylogue.maintenance.rebuild_index.rebuild_index_from_source``), never a -duplicate implementation -- and promote it once exact-ready, with zero -operator involvement (the automagic-invariants doctrine: the daemon -maintains the invariant itself). - -Two properties this module adds on top of the existing rebuild engine: - -* **Parallel, off-writer-hold parse** for the bulk path, by reusing the - #3168 ``DaemonParseStage`` seam: the NEXT bounded pass's raw ids are known - in advance (the transaction's own paged cursor, - ``IndexGenerationStore.next_raw_page``), so they can be pre-parsed in a - bounded thread pool before the writer-coordinated pass ever requests the - writer hold. Degrades gracefully to the existing in-hold sequential parse - on a GIL build or any prefetch miss -- see ``DaemonParseStage`` and - ``RawParsePrefetchCache`` for the equivalence guarantee this rests on. -* **O(remaining-work) interruption recovery** (polylogue-fbte): the daemon - resolves the SAME well-known operation id every tick - (``DAEMON_BULK_REBUILD_OPERATION_ID``), so a daemon restart mid-build finds - the persisted transaction (with its ``last_raw_id``/``processed_raw_count`` - cursor, populated by every bounded pass -- see - ``IndexGenerationStore.checkpoint_transaction``) and resumes from there - instead of re-walking the whole corpus. This is the property fbte - identified as missing from the CLI's own invocation model (an operator - who forgets ``--operation-id`` silently starts a fresh transaction); the - daemon can never make that mistake because it never has an "operation id" - input to forget -- there is exactly one daemon-owned bulk-rebuild - operation per archive, always resolved the same way. -""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -from pathlib import Path -from typing import TYPE_CHECKING - -from polylogue.config import Config -from polylogue.logging import get_logger -from polylogue.maintenance.archive_verification import read_raw_failure_lifecycle -from polylogue.maintenance.rebuild_index import ( - _REBUILD_TERMINAL_NOT_RESUMABLE, - _reconcile_active_generation_transaction, - candidate_build_schema_identity, - freeze_candidate_source_inputs, - validate_rebuild_source_admission, - verify_frozen_candidate_source_inputs, -) -from polylogue.storage.archive_identity import ( - ArchiveLocation, - ArchiveLocationError, - OwnedArchiveLocation, - assert_owns_archive_location, -) -from polylogue.storage.index_generation import ( - IndexGenerationStore, - IndexRebuildTransaction, - rebuild_source_evidence_snapshot, -) - -if TYPE_CHECKING: - from polylogue.daemon.parse_prefetch import DaemonParseStage - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt - -logger = get_logger(__name__) - - -def _candidate_source_cut_path(root: Path, generation_id: str) -> Path: - return root / ".candidate-source-cuts" / generation_id - - -def _retire_candidate_source_cut(root: Path, generation_id: str) -> None: - """Remove a frozen cut when its owning generation leaves the candidate lifecycle.""" - cut = _candidate_source_cut_path(root, generation_id) - if not cut.exists() and not cut.is_symlink(): - return - if cut.is_symlink(): - raise RuntimeError(f"candidate source cut is a symlink: {cut}") - shutil.rmtree(cut) - parent_fd = os.open(cut.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - try: - os.fsync(parent_fd) - finally: - os.close(parent_fd) - - -def _validate_rebuild_provenance_receipt(root: Path, receipt_path: Path | None) -> None: - """Validate daemon rebuild provenance at the current ownership boundary.""" - from polylogue.maintenance.schema_inference_gate import ( - SchemaInferenceGateError, - validate_schema_inference_receipt, - ) - - try: - validate_schema_inference_receipt(root, receipt_path) - except SchemaInferenceGateError as exc: - raise RuntimeError(f"daemon bulk rebuild schema-inference preflight gate failed: {exc}") from exc - - -def _discard_daemon_transaction_after_provenance_failure( - store: IndexGenerationStore, transaction: IndexRebuildTransaction -) -> list[BaseException]: - """Attempt candidate and transaction cleanup independently. - - The caller still owns the archive location. Cleanup does not consume source - evidence, so it must remain available even when the receipt that authorized - candidate creation has just expired or the source snapshot has drifted. - Each record is retired independently so one failed cleanup step cannot - prevent the other record from being attempted. False return values are - failures too: a daemon cleanup path must never silently strand an inactive - candidate. - """ - errors: list[BaseException] = [] - candidate_retired = False - try: - generation = store.load(transaction.generation_id) - if generation.state != "inactive": - errors.append(RuntimeError(f"candidate {generation.generation_id} is not inactive")) - elif not store.discard_if_inactive(generation): - errors.append(RuntimeError(f"candidate {generation.generation_id} was not discarded")) - else: - candidate_retired = True - except BaseException as exc: - logger.error("bulk-rebuild: candidate discard raised", exc_info=True) - cleanup_error = RuntimeError(f"candidate {transaction.generation_id} discard failed: {exc}") - cleanup_error.__cause__ = exc - errors.append(cleanup_error) - if candidate_retired: - try: - _retire_candidate_source_cut(store.archive_root, transaction.generation_id) - except BaseException as exc: - errors.append(RuntimeError(f"candidate source cut {transaction.generation_id} retirement failed: {exc}")) - try: - if not store.discard_transaction(transaction.operation_id): - errors.append(RuntimeError(f"transaction {transaction.operation_id} was not discarded")) - except BaseException as exc: - logger.error("bulk-rebuild: transaction discard raised", exc_info=True) - cleanup_error = RuntimeError(f"transaction {transaction.operation_id} discard failed: {exc}") - cleanup_error.__cause__ = exc - errors.append(cleanup_error) - return errors - - -def _surface_daemon_cleanup_failures( - primary: BaseException, cleanup_errors: list[BaseException], *, label: str -) -> None: - """Keep the primary failure while surfacing every cleanup outcome.""" - if cleanup_errors: - detail = "; ".join(f"{type(error).__name__}: {error}" for error in cleanup_errors) - primary.add_note(f"{label} cleanup also failed: {detail}") - - -def _raise_daemon_cleanup_failures(cleanup_errors: list[BaseException], *, label: str) -> None: - """Raise when terminal daemon cleanup itself is the primary failure.""" - if cleanup_errors: - detail = "; ".join(f"{type(error).__name__}: {error}" for error in cleanup_errors) - raise RuntimeError(f"{label} cleanup failed: {detail}") from cleanup_errors[0] - - -#: Fixed operation id for the daemon's own bulk-rebuild transaction. Exactly -#: one such operation is ever in flight per archive -- this module's only -#: caller is a single daemon asyncio loop -- so a well-known id lets every -#: tick resolve the same resumable transaction with an O(1) file read -#: instead of scanning every transaction under -#: ``.index-rebuild-transactions/``. This also keeps the daemon's own -#: automagic operation distinct from any operator-run -#: ``polylogue ops maintenance rebuild-index`` invocation, which always -#: mints its own random operation id and is untouched by this module. -DAEMON_BULK_REBUILD_OPERATION_ID = "daemon-bulk-rebuild" - -#: Raw rows scheduled per bounded pass -- mirrors the offline CLI's own -#: default (``RebuildIndexRequest.raw_batch_size``), small enough to keep -#: the writer coordinator responsive to interleaved live-ingest/trickle -#: writer actors between passes. -DAEMON_BULK_REBUILD_BATCH_SIZE = 500 - - -async def _await_parse_stage_writer_admission(parse_stage: DaemonParseStage) -> bool: - """Prove the bulk parse stage is idle before any coordinator admission. - - ``warm_raw_ids`` may outlive its cancelled ``to_thread`` caller. Every - coordinator request in this pass therefore checks the worker fence first; - repeated calls remain blocked until the same workers really finish. - """ - if parse_stage.writer_admission_ready(): - return True - idle = await asyncio.to_thread( - parse_stage.wait_until_idle, - timeout=parse_stage.warm_timeout_seconds, - ) - if not idle: - logger.warning("bulk-rebuild: deferring coordinator admission while parse-stage worker(s) remain active") - return bool(idle) - - -#: Transaction statuses that mean "not resumable, retire and start fresh at -#: the same well-known operation id": ``promoted`` (a prior build already -#: succeeded and is now the active index), ``promoted-attestation-failed`` (a -#: build succeeded and is active, but a post-promotion receipt write failed), -#: ``stale`` (source evidence changed mid-build), and ``failed`` (a pass -#: raised; automagic doctrine retries rather than waiting on an operator to -#: intervene). -_TERMINAL_NOT_RESUMABLE = _REBUILD_TERMINAL_NOT_RESUMABLE - - -def _preflight_raw_failure_lifecycle(root: Path) -> None: - """Refuse daemon rebuild mutation while raw failures are unexplained.""" - snapshot = read_raw_failure_lifecycle(root / "source.db", sample_limit=10) - if not snapshot.blocking: - return - if snapshot.available: - reason = f"{snapshot.unexplained} raw failure(s) lack matching typed lifecycle evidence" - else: - reason = snapshot.reason or "raw failure lifecycle is unavailable" - raise RuntimeError(f"daemon bulk-rebuild raw failure lifecycle preflight failed: {reason}") - - -def resolve_or_start_daemon_bulk_rebuild_transaction( - root: Path, - *, - schema_inference_receipt_path: Path | None = None, -) -> IndexRebuildTransaction: - """Load the daemon's resumable bulk-rebuild transaction, starting one if needed. - - Read-only fast path when a resumable transaction already exists (a - single JSON read); only touches the filesystem otherwise, and only to - retire a terminal transaction/generation before creating a fresh one at - the SAME well-known operation id (see ``DAEMON_BULK_REBUILD_OPERATION_ID``). - Never touches the ACTIVE index or ``source.db`` -- a fresh generation is - a brand-new SQLite file under ``.index-generations/``. - - Acquires :class:`~polylogue.storage.archive_identity.OwnedArchiveLocation` - over ``root`` before any of that mutation happens (polylogue-ovme.2.1): - this is the online/daemon-driven counterpart to - ``rebuild_index_from_source``'s own offline ownership acquisition - (polylogue-ovme.2 AC3) -- both discard a stale candidate and mint a fresh - generation directory, so both must fail closed against a foreign/rotated - archive location before touching disk, not just the eventual write pass. - """ - from polylogue.maintenance.rebuild_index import require_rebuild_schema_currency - - require_rebuild_schema_currency(root) - _validate_rebuild_provenance_receipt(root, schema_inference_receipt_path) - # This must precede transaction resolution, because retiring a terminal - # transaction and creating its replacement also creates generation state. - # It must also precede the caller's page selection for a resumed - # transaction, so no raw is selected while the source failure universe is - # not in a known lifecycle state. - _preflight_raw_failure_lifecycle(root) - location = ArchiveLocation.resolve(root) - owned = OwnedArchiveLocation.acquire(location) - try: - assert_owns_archive_location(owned, location) - # The early check is a cheap rejection before receipt work. Repeat it - # under archive ownership because a previous owner can migrate a - # durable tier while this caller waits for the lock. - require_rebuild_schema_currency(root) - # The first validation is only a cheap early rejection. Revalidate - # after ownership acquisition so receipt expiry, source revision, or - # external-corpus drift cannot reach generation bookkeeping. - _validate_rebuild_provenance_receipt(root, schema_inference_receipt_path) - store = IndexGenerationStore(location, repair_anchor=False) - transaction: IndexRebuildTransaction | None - try: - transaction = store.load_transaction(DAEMON_BULK_REBUILD_OPERATION_ID) - except FileNotFoundError: - transaction = None - except (OSError, ValueError, TypeError, KeyError) as exc: - logger.warning( - "bulk-rebuild: could not load persisted transaction %s; starting a fresh one: %s", - DAEMON_BULK_REBUILD_OPERATION_ID, - exc, - ) - transaction = None - - if transaction is not None and transaction.status not in _TERMINAL_NOT_RESUMABLE: - transaction = _reconcile_active_generation_transaction(store, transaction) - if transaction.status not in _TERMINAL_NOT_RESUMABLE: - _validate_rebuild_provenance_receipt(root, schema_inference_receipt_path) - return transaction - - # A fresh transaction receives one archive-wide source admission. - # Resumed passes validate only their selected authority component in - # rebuild_index_from_source_sync, avoiding a full reparse per page. - validate_rebuild_source_admission(root, location) - - if transaction is not None: - if transaction.status == "promoted-attestation-failed": - # The generation is already active. Preserve the terminal - # attestation failure while its source evidence is unchanged. - # A later, receipt-authorized source change needs a fresh - # daemon transaction, but must retain the active generation. - current_source_snapshot = rebuild_source_evidence_snapshot(root) - if current_source_snapshot == transaction.source_snapshot: - return transaction - if not store.discard_transaction(DAEMON_BULK_REBUILD_OPERATION_ID): - raise RuntimeError( - f"transaction {DAEMON_BULK_REBUILD_OPERATION_ID} was not discarded after source drift" - ) - _retire_candidate_source_cut(root, transaction.generation_id) - transaction = None - if transaction is None: - pass - else: - # Terminal: retire the old candidate/transaction record before - # reusing the well-known operation id. A promoted generation, - # including one with a failed post-promotion attestation, is - # already the active index (nothing to discard); "stale/failed" - # candidates are still inactive and safe to discard. - cleanup_errors: list[BaseException] = [] - candidate_retired = transaction.status in {"promoted", "promoted-attestation-failed"} - if transaction.status not in {"promoted", "promoted-attestation-failed"}: - try: - generation = store.load(transaction.generation_id) - except BaseException as exc: - logger.error("bulk-rebuild: terminal candidate load failed", exc_info=True) - cleanup_errors.append(exc) - else: - if generation.state != "inactive": - cleanup_errors.append(RuntimeError(f"candidate {generation.generation_id} is not inactive")) - else: - try: - if not store.discard_if_inactive(generation): - cleanup_errors.append( - RuntimeError(f"candidate {generation.generation_id} was not discarded") - ) - else: - candidate_retired = True - except BaseException as exc: - logger.error("bulk-rebuild: terminal candidate discard raised", exc_info=True) - cleanup_error = RuntimeError( - f"candidate {generation.generation_id} discard failed: {exc}" - ) - cleanup_error.__cause__ = exc - cleanup_errors.append(cleanup_error) - if candidate_retired: - try: - _retire_candidate_source_cut(root, transaction.generation_id) - except BaseException as exc: - cleanup_errors.append( - RuntimeError(f"candidate source cut {transaction.generation_id} retirement failed: {exc}") - ) - try: - if not store.discard_transaction(DAEMON_BULK_REBUILD_OPERATION_ID): - cleanup_errors.append( - RuntimeError(f"transaction {DAEMON_BULK_REBUILD_OPERATION_ID} was not discarded") - ) - except BaseException as exc: - logger.error("bulk-rebuild: terminal transaction discard failed", exc_info=True) - cleanup_errors.append(exc) - _raise_daemon_cleanup_failures(cleanup_errors, label="daemon bulk-rebuild terminal") - - _validate_rebuild_provenance_receipt(root, schema_inference_receipt_path) - source_snapshot = rebuild_source_evidence_snapshot(root) - transaction = store.create_transaction( - source_snapshot=source_snapshot, - operation_id=DAEMON_BULK_REBUILD_OPERATION_ID, - ) - try: - _validate_rebuild_provenance_receipt(root, schema_inference_receipt_path) - if rebuild_source_evidence_snapshot(root) != source_snapshot: - raise RuntimeError("daemon bulk rebuild source evidence changed during transaction creation") - except BaseException as exc: - cleanup_errors = _discard_daemon_transaction_after_provenance_failure(store, transaction) - _surface_daemon_cleanup_failures(exc, cleanup_errors, label="daemon bulk-rebuild transaction") - raise - return transaction - finally: - owned.release() - - -def has_resumable_daemon_bulk_rebuild_transaction(root: Path) -> bool: - """Whether a daemon bulk-rebuild operation is already in progress. - - Read-only: never creates or discards anything. Used to decide whether - to keep driving an in-flight build even when the instantaneous - raw-materialization backlog reading has dipped below the bulk-scale - threshold -- abandoning a partially-built generation mid-flight would - waste every page already replayed into it. - """ - try: - pointer_target = ArchiveLocation.resolve(root).active_index_path - transaction_path = ( - pointer_target.parent / ".index-rebuild-transactions" / f"{DAEMON_BULK_REBUILD_OPERATION_ID}.json" - ) - transaction = IndexRebuildTransaction(**json.loads(transaction_path.read_text(encoding="utf-8"))) - except (ArchiveLocationError, FileNotFoundError, OSError, ValueError, TypeError, KeyError): - return False - return transaction.status not in _TERMINAL_NOT_RESUMABLE - - -async def run_daemon_bulk_rebuild_pass( - *, - config: Config, - parse_stage: DaemonParseStage, - batch_size: int = DAEMON_BULK_REBUILD_BATCH_SIZE, - max_payload_bytes: int, - candidate_build: bool = False, -) -> RebuildIndexReceipt | None: - """Drive one bounded daemon-owned bulk-rebuild pass. - - Returns ``None`` when the operation is already ``promoted`` (nothing to - do this tick -- the caller's next threshold check will decide whether a - new operation is warranted). Otherwise pre-warms the NEXT page's parse - off the writer hold (the #3168 ``DaemonParseStage`` seam) before - scheduling the writer-coordinated pass, so the writer hold covers mostly - already-parsed SQLite writes rather than CPU-bound decode. - - The actual write pass reuses - ``polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync`` - unmodified -- the SAME engine the offline CLI rebuild command drives -- - scheduled through the daemon's single write coordinator exactly like - every other daemon writer actor (single-writer invariant: this module - never opens a second writer connection of its own). - """ - from polylogue.daemon.write_coordinator import daemon_write_coordinator - from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - rebuild_index_from_source_sync, - require_rebuild_schema_currency, - ) - from polylogue.maintenance.schema_inference_gate import resolve_schema_inference_receipt_reference - - root = Path(config.archive_root) - receipt_path = resolve_schema_inference_receipt_reference(root) - # Admission is deliberately before transaction resolution: even a - # read-mostly/resumed admission acquires the coordinator, and a timed-out - # parse worker remains alive after its to_thread caller is cancelled. - if not await _await_parse_stage_writer_admission(parse_stage): - return None - transaction = await daemon_write_coordinator().run_sync( - "maintenance.bulk_rebuild_admission", - resolve_or_start_daemon_bulk_rebuild_transaction, - root, - schema_inference_receipt_path=receipt_path, - ) - if transaction.status in {"promoted", "promoted-attestation-failed"}: - return None - - location = ArchiveLocation.resolve(root) - owned = await asyncio.to_thread(OwnedArchiveLocation.acquire, location) - try: - await asyncio.to_thread(assert_owns_archive_location, owned, location) - # Transaction resolution has its own ownership-bound currency check, - # but a migration can complete before this later page-selection hold. - # Recheck before consuming the receipt or selecting source material. - await asyncio.to_thread(require_rebuild_schema_currency, root) - await asyncio.to_thread(_validate_rebuild_provenance_receipt, root, receipt_path) - store = IndexGenerationStore(location, repair_anchor=False) - await asyncio.to_thread(_validate_rebuild_provenance_receipt, root, receipt_path) - page = await asyncio.to_thread(store.next_raw_page, transaction, limit=batch_size) - finally: - owned.release() - raw_ids = [raw_id for raw_id, _blob_hash_hex, _blob_size in page.rows] - if raw_ids: - warmed = await asyncio.to_thread( - parse_stage.warm_raw_ids, - config, - raw_ids=raw_ids, - max_payload_bytes=max_payload_bytes, - ) - if warmed: - logger.info( - "bulk-rebuild: parse-stage prefetch warmed %d of %d raw(s) for the next pass off the writer hold", - warmed, - len(raw_ids), - ) - # A timed-out warm caller can return while its executor worker is - # still parsing. Do not queue the actual bulk writer until the shared - # parse-stage admission has drained; this mirrors the ordinary and - # whale repair routes without touching the live watcher path. - - if not parse_stage.writer_admission_ready(): - idle = await asyncio.to_thread( - parse_stage.wait_until_idle, - timeout=parse_stage.warm_timeout_seconds, - ) - if not idle: - logger.warning("bulk-rebuild: deferring writer admission while parse-stage worker(s) remain active") - return None - - candidate_operation = None - if candidate_build: - # Candidate mode is lowered through the canonical operation contract - # before reaching the shared replay engine. The boolean remains an - # internal daemon routing decision only; it is not a wire/request - # capability and it cannot carry caller-selected paths or knobs. - from polylogue.maintenance.archive_verification import archive_verification_domain_adapters - from polylogue.maintenance.domain_check_plan import ( - compile_domain_check_plan, - declarations_from_outcome_owners, - ) - from polylogue.operations.candidate_build import ( - CandidateBuildBudget, - CandidateBuildObligation, - CandidateBuildPlanningContext, - CandidateBuildRequest, - SourceSeal, - plan_candidate_build, - ) - - check_plan = compile_domain_check_plan( - declarations_from_outcome_owners(archive_verification_domain_adapters(root), phase="candidate"), - phase="candidate", - ) - - root_stat = root.stat() - source_tier = location.configured_tier("source") - source_schema_version, schema_declarations = candidate_build_schema_identity() - cut_destination = root / ".candidate-source-cuts" / transaction.generation_id - source_cut = await asyncio.to_thread( - freeze_candidate_source_inputs, - config, - destination=cut_destination, - request_id=f"candidate-{transaction.generation_id}", - fallback_source_path=source_tier.configured_path, - ) - - def recompute_source_seal() -> SourceSeal: - current_cut = verify_frozen_candidate_source_inputs(cut_destination) - return SourceSeal( - archive_identity=f"dev:{root_stat.st_dev}:ino:{root_stat.st_ino}", - source_identity=source_tier.stable_id, - source_snapshot=transaction.source_snapshot, - source_schema_version=source_schema_version, - cut_identity=current_cut.cut_identity, - candidate_manifest_digest=current_cut.candidate_manifest.digest, - carry_forward_manifest_digest=current_cut.carry_forward_manifest.digest, - ) - - source_seal = SourceSeal( - archive_identity=f"dev:{root_stat.st_dev}:ino:{root_stat.st_ino}", - source_identity=source_tier.stable_id, - source_snapshot=transaction.source_snapshot, - source_schema_version=source_schema_version, - cut_identity=source_cut.cut_identity, - candidate_manifest_digest=source_cut.candidate_manifest.digest, - carry_forward_manifest_digest=source_cut.carry_forward_manifest.digest, - ) - candidate_request = CandidateBuildRequest( - source_seal=source_seal, - package="polylogue-index", - code="polylogue-rebuild-runtime", - schemas=schema_declarations, - parser_declarations=("origin-parser-fingerprints",), - lowering_declarations=("archive-lowering-fingerprints",), - origin_declarations=("canonical-origin-specs",), - recipe_version="rebuild-recipe-v1", - semantic_version="rebuild-semantics-v1", - check_plan_digest=check_plan.digest, - check_plan_members=check_plan.member_identities, - ) - generation = await asyncio.to_thread(store.load, transaction.generation_id) - candidate_plan = plan_candidate_build( - candidate_request, - CandidateBuildPlanningContext( - archive_root=root, - source_seal=source_seal, - generation=generation, - budget=CandidateBuildBudget( - source_bytes=0, - work_units=batch_size, - memory_bytes=max_payload_bytes, - ), - obligations=( - CandidateBuildObligation(name="source-seal"), - CandidateBuildObligation(name="lineage"), - CandidateBuildObligation(name="inactive-generation"), - ), - recompute_source_seal=recompute_source_seal, - expected_check_plan_digest=check_plan.digest, - expected_check_plan_members=check_plan.member_identities, - ), - ) - if candidate_plan.request_digest != candidate_request.identity_digest: - raise RuntimeError("candidate build plan lost request identity") - candidate_operation = candidate_request - - request = RebuildIndexRequest( - archive_root=root, - promote=not candidate_build, - candidate_operation=candidate_operation, - operation_id=transaction.operation_id, - schema_inference_receipt_path=receipt_path, - raw_batch_size=batch_size, - prefetch_cache=parse_stage.cache, - ) - return await daemon_write_coordinator().run_sync( - "maintenance.bulk_rebuild", - rebuild_index_from_source_sync, - request, - ) - - -def run_daemon_canary_rebuild( - *, - archive_root: Path, - raw_ids: tuple[str, ...], - selected_session_ids: tuple[str, ...], - index_schema_version: int, - schema_inference_receipt_path: Path, -) -> dict[str, object]: - """Run the canary only through the running daemon's writer-owned HTTP route.""" - - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - from polylogue.daemon.socket_path import daemon_socket_path - from polylogue.daemon_client import DaemonClient - from polylogue.version import POLYLOGUE_VERSION - - root = Path(archive_root).resolve() - config = load_polylogue_config() - client = DaemonClient( - daemon_socket_path(root), - timeout_s=None, - auth_token=resolve_api_auth_token( - config.api_auth_token, allow_no_auth=config.api_allow_no_auth, token_path=root / "api-auth-token" - ), - ) - if ( - client.probe( - archive_root=str(root), - index_schema_version=index_schema_version, - daemon_version=POLYLOGUE_VERSION, - accept_degraded=True, - ) - is None - ): - raise RuntimeError("reindex canary requires a matching running daemon writer") - payload: dict[str, object] = { - "raw_ids": list(raw_ids), - "selected_session_ids": list(selected_session_ids), - "promote": False, - "canary": True, - "schema_inference_receipt_path": str(schema_inference_receipt_path.resolve()), - } - receipt = client.request_json("POST", "/api/maintenance/rebuild-index", payload) - if receipt is None: - raise RuntimeError("daemon rejected or did not complete the reindex canary rebuild") - return receipt - - -def discard_daemon_canary_candidate( - *, - archive_root: Path, - generation_id: str, - generation_owner_id: str, -) -> None: - """Discard a failed canary candidate through its owning daemon process.""" - - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - from polylogue.daemon.socket_path import daemon_socket_path - from polylogue.daemon_client import DaemonClient - - root = Path(archive_root).resolve() - config = load_polylogue_config() - client = DaemonClient( - daemon_socket_path(root), - timeout_s=None, - auth_token=resolve_api_auth_token( - config.api_auth_token, allow_no_auth=config.api_allow_no_auth, token_path=root / "api-auth-token" - ), - ) - result = client.request_json( - "POST", - "/api/maintenance/discard-index-candidate", - {"generation_id": generation_id, "generation_owner_id": generation_owner_id}, - ) - if result != {"discarded": True, "generation_id": generation_id}: - raise RuntimeError(f"daemon did not discard inactive canary candidate {generation_id}") - - -def seal_daemon_canary_comparison( - *, archive_root: Path, generation_id: str, generation_owner_id: str -) -> dict[str, object]: - """Ask the candidate-owning daemon to seal its historical comparison.""" - - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - from polylogue.daemon.socket_path import daemon_socket_path - from polylogue.daemon_client import DaemonClient, DaemonResponseError - from polylogue.maintenance.reindex_canary import UnclassifiedCanaryDiffError - - root = Path(archive_root).resolve() - config = load_polylogue_config() - client = DaemonClient( - daemon_socket_path(root), - timeout_s=None, - auth_token=resolve_api_auth_token( - config.api_auth_token, allow_no_auth=config.api_allow_no_auth, token_path=root / "api-auth-token" - ), - ) - try: - payload = client.request_json( - "POST", - "/api/maintenance/seal-canary-comparison", - {"generation_id": generation_id, "generation_owner_id": generation_owner_id}, - raise_for_status=True, - ) - except DaemonResponseError as exc: - if 400 <= exc.status < 500: - raise UnclassifiedCanaryDiffError(exc.detail) from exc - raise RuntimeError(str(exc)) from exc - if not isinstance(payload, dict): - raise RuntimeError("daemon rejected or did not complete canary comparison sealing") - return payload - - -def consume_daemon_canary_report(*, archive_root: Path, report_path: Path) -> dict[str, object]: - """Consume report evidence through the archive's running daemon writer.""" - - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - from polylogue.daemon.socket_path import daemon_socket_path - from polylogue.daemon_client import DaemonClient, DaemonResponseError - from polylogue.maintenance.reindex_canary import UnclassifiedCanaryDiffError - - root = Path(archive_root).resolve() - config = load_polylogue_config() - client = DaemonClient( - daemon_socket_path(root), - timeout_s=None, - auth_token=resolve_api_auth_token( - config.api_auth_token, allow_no_auth=config.api_allow_no_auth, token_path=root / "api-auth-token" - ), - ) - try: - payload = client.request_json( - "POST", - "/api/maintenance/consume-canary-report", - {"report_path": str(Path(report_path).resolve())}, - raise_for_status=True, - ) - except DaemonResponseError as exc: - if 400 <= exc.status < 500: - raise UnclassifiedCanaryDiffError(exc.detail) from exc - raise RuntimeError(str(exc)) from exc - if not isinstance(payload, dict): - raise RuntimeError("daemon rejected or did not complete canary report consumption") - return payload - - -__all__ = [ - "DAEMON_BULK_REBUILD_BATCH_SIZE", - "DAEMON_BULK_REBUILD_OPERATION_ID", - "consume_daemon_canary_report", - "discard_daemon_canary_candidate", - "has_resumable_daemon_bulk_rebuild_transaction", - "resolve_or_start_daemon_bulk_rebuild_transaction", - "run_daemon_canary_rebuild", - "seal_daemon_canary_comparison", - "run_daemon_bulk_rebuild_pass", -] diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index b2bcdfef03..047d1ca446 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -167,21 +167,6 @@ # A spool file younger than this is in the live route's normal debounce/ # batch flow, not stalled; only older cursor-less files park the conveyor. _SPOOL_PENDING_GRACE_SECONDS = 300 -# Lesson from the 2026-07-18/19 restore (polylogue-m6tp, polylogue-5jak): the -# trickle conveyor's bounded per-pass passes are sized for steady-state -# drift, not a bulk-scale backlog. Above this many pending raws or this many -# pending bytes, grinding through bounded passes turns ~1h of parse work into -# a weeks-scale projection; `polylogue ops maintenance rebuild-index` (a -# resumable blue-green generation rebuild) does the same work in one sweep. -# The daemon does not switch to that path itself (open questions: pausing -# the watcher, a frozen source-snapshot requirement, and the restart story — -# tracked as a polylogue-m6tp follow-up); it only recommends it, loudly and -# rate-limited so a long-lived backlog doesn't spam the journal every pass. -_BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD = 2_000 -_BULK_REBUILD_RECOMMENDATION_BYTES_THRESHOLD = 2 * 1024 * 1024 * 1024 # 2 GiB -_BULK_REBUILD_RECOMMENDATION_MIN_INTERVAL_SECONDS = 3600.0 # at most once/hour -_last_bulk_rebuild_recommendation_monotonic: float | None = None - # polylogue-m6tp phase (a): one parse-stage warmer lives for the daemon # process's lifetime, lazily created on first use. It is deliberately # module-level (not per-pass) so its bounded ``ThreadPoolExecutor`` and @@ -200,24 +185,6 @@ def _daemon_parse_stage() -> DaemonParseStage: return _daemon_parse_stage_singleton -# polylogue-gd6v: a SEPARATE parse-stage instance (own bounded thread pool + -# prefetch cache) from the trickle conveyor's ``_daemon_parse_stage()`` -# above. Trickle mode and bulk-rebuild routing are independent lifecycles -# (see docs/design/convergence-simplification-inventory.md's "trickle mode -# stays... bulk mode adds only" framing) that can be in flight -# simultaneously; sharing one pool would let one starve the other's budget. -_daemon_bulk_rebuild_parse_stage_singleton: DaemonParseStage | None = None - - -def _daemon_bulk_rebuild_parse_stage() -> DaemonParseStage: - global _daemon_bulk_rebuild_parse_stage_singleton - if _daemon_bulk_rebuild_parse_stage_singleton is None: - from polylogue.daemon.parse_prefetch import DaemonParseStage - - _daemon_bulk_rebuild_parse_stage_singleton = DaemonParseStage() - return _daemon_bulk_rebuild_parse_stage_singleton - - async def _maybe_warm_raw_materialization_parse_stage( *, limit: int, @@ -992,146 +959,6 @@ async def _run_periodic_fts_convergence_once(db: Path) -> None: ) -def _bulk_scale_raw_materialization_backlog(counts: RawMaterializationCounts) -> bool: - """Whether a pass's measured backlog is bulk-scale, not steady-state drift.""" - return ( - counts.candidate_count > _BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD - or counts.pending_blob_bytes > _BULK_REBUILD_RECOMMENDATION_BYTES_THRESHOLD - ) - - -def _maybe_recommend_bulk_rebuild(counts: RawMaterializationCounts) -> None: - """Loudly recommend the bulk rebuild path once a backlog is bulk-scale. - - This only journals a recommendation; it does not run the bulk path - itself (see the module-level constants' comment and polylogue-m6tp for - why: watcher-pause, frozen source-snapshot, and restart semantics are - still open questions for a daemon-driven bulk path). Rate-limited to at - most once per hour per daemon process so a long-lived backlog doesn't - re-log every burst pass. - """ - global _last_bulk_rebuild_recommendation_monotonic - if not _bulk_scale_raw_materialization_backlog(counts): - return - now = time.monotonic() - last = _last_bulk_rebuild_recommendation_monotonic - if last is not None and (now - last) < _BULK_REBUILD_RECOMMENDATION_MIN_INTERVAL_SECONDS: - return - _last_bulk_rebuild_recommendation_monotonic = now - logger.warning( - "raw materialization: backlog is bulk-scale (%d candidate(s), %.2f GiB pending) -- " - "the trickle conveyor is sized for steady-state drift and can take weeks on a backlog " - "this size; run `polylogue ops maintenance rebuild-index` for a resumable blue-green " - "bulk rebuild instead of waiting on this conveyor", - counts.candidate_count, - counts.pending_blob_bytes / (1024**3), - ) - - -async def _daemon_bulk_rebuild_transaction_in_flight() -> bool: - """Whether the daemon's own well-known bulk-rebuild transaction is running. - - Read-only fast path (at most one JSON file read). Used by - ``_periodic_raw_materialization_convergence`` to stand its trickle - census/drain pass down for the current tick instead of duplicating work - the bulk-rebuild engine already subsumes: both walk the SAME - ``raw_sessions`` -> index materialization pipeline over the same backlog - (see docs/design/convergence-simplification-inventory.md item 5 -- the - bulk engine's own paged cursor query is unfiltered by any fixed - snapshot, so it naturally absorbs raws that arrive while it runs; there - is no work left for the trickle pass to do on the SAME raws in the - meantime). - - Checking survives a daemon restart via a durable transaction record. - """ - from polylogue.daemon.bulk_rebuild import has_resumable_daemon_bulk_rebuild_transaction - from polylogue.maintenance.schema_inference_gate import ( - SchemaInferenceGateError, - resolve_schema_inference_receipt_reference, - validate_schema_inference_receipt, - ) - from polylogue.paths import archive_root - - root = archive_root() - try: - receipt_path = resolve_schema_inference_receipt_reference(root) - await asyncio.to_thread(validate_schema_inference_receipt, root, receipt_path) - except (SchemaInferenceGateError, RuntimeError, OSError, ValueError): - return False - return await asyncio.to_thread(has_resumable_daemon_bulk_rebuild_transaction, root) - - -async def _maybe_route_daemon_bulk_rebuild(counts: RawMaterializationCounts) -> bool: - """polylogue-gd6v: route a bulk-scale backlog into a daemon-owned blue-green rebuild. - - Unconditional. This was gated behind a ``daemon_bulk_rebuild_routing`` - config flag that defaulted off, which meant the ONLY path that ever ran - was the operator hand-driving ``ops maintenance rebuild-index`` -- the - exact inversion polylogue-gd6v's own acceptance criteria forbid ("no - break-glass residue -- redundant manual surfaces are purged, not - demoted"). The measured cost of leaving it off: the two rebuilds that - actually happened were hand-resumed across days, 88% and 69% of their - wall-clock idle, because nothing drove them between operator sessions. - A flag whose off-state is strictly worse is not a choice; it is a defect - with a toggle. Once a bulk-rebuild transaction is in flight (this tick or a prior one, - surviving a daemon restart -- see ``polylogue.daemon.bulk_rebuild``), - keeps driving it every tick regardless of the instantaneous trickle - backlog reading: abandoning a partially-built generation mid-flight - would waste every page already replayed into it. This runs after the - trickle pass has already released the writer coordinator, so scheduling - another writer-coordinated pass here is safe. - - Returns whether a pass was genuinely attempted this call (``True``) or - the call was a structural no-op / hit a swallowed failure (``False``). - ``_periodic_raw_materialization_convergence``'s trickle-suppression - branch (residual of polylogue-gd6v) uses this in place of the trickle - pass's own ``made_progress`` signal to decide whether to keep bursting - bulk-rebuild passes back-to-back or fall back to the slower outer - interval -- a genuine pass failure must not turn into a tight 1s retry - storm, matching how a trickle pass failure already falls back to the - outer interval via the caller's exception handling. - """ - from polylogue.config import Config - from polylogue.daemon.bulk_rebuild import ( - DAEMON_BULK_REBUILD_OPERATION_ID, - run_daemon_bulk_rebuild_pass, - ) - from polylogue.paths import archive_root, render_root - - root = archive_root() - if not _bulk_scale_raw_materialization_backlog(counts) and not await _daemon_bulk_rebuild_transaction_in_flight(): - return False - config = Config(archive_root=root, render_root=render_root(), sources=[]) - try: - receipt = await run_daemon_bulk_rebuild_pass( - config=config, - parse_stage=_daemon_bulk_rebuild_parse_stage(), - max_payload_bytes=_RAW_MATERIALIZATION_DAEMON_BLOB_LIMIT_BYTES, - candidate_build=True, - ) - except Exception: - logger.warning("bulk-rebuild: routed pass failed", exc_info=True) - return False - if receipt is None: - return True - transaction_status = str(receipt.transaction["status"]) if receipt.transaction else receipt.status - processed = receipt.transaction.get("processed_raw_count") if receipt.transaction else None - logger.info( - "bulk-rebuild: pass status=%s transaction_status=%s processed_raw_count=%s selected=%d", - receipt.status, - transaction_status, - processed, - receipt.selected_raw_count, - ) - if transaction_status == "promoted": - logger.warning( - "bulk-rebuild: promoted a daemon-built generation covering the backlog (operation %s); " - "the trickle conveyor's remaining backlog reflects the new active index from the next tick", - DAEMON_BULK_REBUILD_OPERATION_ID, - ) - return True - - async def _periodic_raw_materialization_convergence( *, catch_up_complete: asyncio.Event | None = None, @@ -1160,49 +987,11 @@ async def _periodic_raw_materialization_convergence( recover = True # polylogue-t93b: set True only at the genuine-quiescence break below # (no progress AND no remaining ordinary-envelope candidates this - # tick) -- never on the bulk-rebuild-in-flight or spool-pending - # breaks, which are deferrals for unrelated reasons, not evidence the - # ordinary backlog is settled. + # tick) -- never on the spool-pending break, which is a deferral for + # an unrelated reason, not evidence the ordinary backlog is settled. quiescent = False try: while True: - # polylogue-gd6v residual: while the daemon's own bulk-rebuild - # transaction is in flight, it already subsumes exactly the - # raw->index materialization work this pass would do over - # the SAME backlog (see - # ``_daemon_bulk_rebuild_transaction_in_flight`` and - # docs/design/convergence-simplification-inventory.md item 5). - # Standing the trickle census/drain pass down here avoids - # both mechanisms converging on the same raws every tick -- - # double parse/replay work plus needless writer-hold - # contention between the two. Everything else the trickle - # conveyor is NOT responsible for (live-ingest acquisition, - # hook-spool drain, embedding catch-up, already-committed - # session insights) lives in separate periodic loops and - # keeps running unaffected. Driving the bulk pass here - # (instead of only from the trickle branch below) also lets - # bulk-rebuild progress burst at the same 1s cadence the - # trickle conveyor uses, rather than waiting a full quiet - # interval between passes. - if await _daemon_bulk_rebuild_transaction_in_flight(): - logger.debug( - "raw materialization: standing down trickle census/drain -- " - "a daemon bulk-rebuild transaction already subsumes this backlog" - ) - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - bulk_progressed = await _maybe_route_daemon_bulk_rebuild(RawMaterializationCounts()) - if not bulk_progressed: - # A swallowed pass failure -- fall back to the slower - # outer interval instead of retrying every burst - # second (mirrors how a trickle pass failure already - # escapes to the outer interval via the exception - # handlers below). - break - if _browser_capture_spool_has_pending_files(): - break - await asyncio.sleep(_RAW_MATERIALIZATION_BACKLOG_BURST_PAUSE_SECONDS) - continue # polylogue-m6tp item 4: census throughput is no longer bounded # by escalating the writer-held pass's OWN limit (the old # ``census_mode`` switch) -- it is bounded by how much the @@ -1236,8 +1025,6 @@ async def _periodic_raw_materialization_convergence( ), ) recover = False - _maybe_recommend_bulk_rebuild(materialized) - await _maybe_route_daemon_bulk_rebuild(materialized) if materialized.made_progress: logger.info( "raw materialization: repaired %d session(s), executed %d frontier plan(s), %d candidate(s) remaining", @@ -1781,9 +1568,8 @@ async def _maybe_run_raw_materialization_whale_pass() -> bool: a permanent offline-only requirement for whale components is the policy bug this closes, so there is no off switch. - Returns whether a pass was genuinely attempted this call, mirroring - ``_maybe_route_daemon_bulk_rebuild``'s return-value contract so the - caller can decide burst-vs-outer-interval pacing the same way. + Returns whether a pass was genuinely attempted this call so the caller + can decide burst-vs-outer-interval pacing. """ from polylogue.config import Config from polylogue.daemon.events import emit_daemon_event @@ -3348,11 +3134,6 @@ async def _run_daemon_services_under_active_writer_lease( # needed: it cannot itself hang the shutdown sequence; it just # stops the pool from keeping the process alive at exit. _daemon_parse_stage_singleton.shutdown() - if _daemon_bulk_rebuild_parse_stage_singleton is not None: - # polylogue-gd6v: same non-blocking shutdown contract as the - # trickle conveyor's parse-stage warmer above, for the - # bulk-rebuild routing's own (separate) pool. - _daemon_bulk_rebuild_parse_stage_singleton.shutdown() if server is not None: await _shutdown_server_if_serving(server, server_task, label="browser-capture") if api_server is not None: diff --git a/polylogue/daemon/convergence_stages.py b/polylogue/daemon/convergence_stages.py index 41a45d372f..4f58519d45 100644 --- a/polylogue/daemon/convergence_stages.py +++ b/polylogue/daemon/convergence_stages.py @@ -966,9 +966,9 @@ def make_raw_parse_recovery_stage(db_path: Path, *, archive_root: Path | None = whether raw rows under the path are still acquired but never materialized, and ``execute`` re-drives ``repair_raw_materialization`` scoped to exactly that path via ``source_root`` -- the same replay engine - the archive-wide trickle conveyor and manual ``ops maintenance - rebuild-index`` already use, just requeued deterministically instead of - waiting for an accidental future touch of the same path. + the archive-wide trickle conveyor already uses, just requeued + deterministically instead of waiting for an accidental future touch of + the same path. """ def check(path: Path) -> bool: diff --git a/polylogue/daemon/embedding_backlog.py b/polylogue/daemon/embedding_backlog.py index 5e2c601408..6aaadef384 100644 --- a/polylogue/daemon/embedding_backlog.py +++ b/polylogue/daemon/embedding_backlog.py @@ -100,7 +100,7 @@ async def periodic_embedding_orphan_reconcile_check( ) -> None: """Periodically reconcile one bounded batch of orphan embedding rows. - An index rebuild (full re-ingest, ``ops maintenance rebuild-index``, a provider + An index rebuild (full re-ingest, an index reset followed by convergence, a provider full-replace parse) can leave ``embeddings.db`` rows pointing at message/session identities that no longer exist in the rebuilt ``index.db`` (polylogue-1dk1). This drains that debt in the background, diff --git a/polylogue/daemon/http.py b/polylogue/daemon/http.py index a1da4a3e4e..2bf78a6aec 100644 --- a/polylogue/daemon/http.py +++ b/polylogue/daemon/http.py @@ -339,7 +339,6 @@ def _static_get_routes() -> tuple[_StaticGetRoute, ...]: _static_get_route("/api/compare", "_handle_compare", passes_params=True), _static_get_route("/api/sources", "_handle_sources"), _static_get_route("/api/thread-continue-templates", "_handle_get_thread_continue_templates"), - _static_get_route("/api/maintenance/operations", "_handle_maintenance_operations"), ) + tuple(route for route in _declared_get_routes() if isinstance(route, _StaticGetRoute)) @@ -452,7 +451,6 @@ def _parameterized_get_routes() -> tuple[_ParameterizedGetRoute, ...]: _parameterized_get_route("/api/insights/sessions/:id", "_handle_get_session_insights", passes_params=True), _parameterized_get_route("/api/webui/insights/:name", "_handle_webui_insight", passes_params=True), _parameterized_get_route("/api/raw_artifacts/:id", "_handle_get_raw_artifact"), - _parameterized_get_route("/api/maintenance/status/:id", "_handle_maintenance_status"), ) + tuple(route for route in _declared_get_routes() if isinstance(route, _ParameterizedGetRoute)) @@ -488,28 +486,6 @@ def _authenticated_post_routes() -> tuple[_StaticPostRoute, ...]: _StaticPostRoute("/api/cli/delete", ("api", "cli", "delete"), "_handle_cli_delete"), _StaticPostRoute("/api/ingest", ("api", "ingest"), "_handle_ingest"), _StaticPostRoute("/api/demo/augment", ("api", "demo", "augment"), "_handle_demo_augment"), - _StaticPostRoute("/api/maintenance/plan", ("api", "maintenance", "plan"), "_handle_maintenance_plan"), - _StaticPostRoute("/api/maintenance/run", ("api", "maintenance", "run"), "_handle_maintenance_run"), - _StaticPostRoute( - "/api/maintenance/rebuild-index", - ("api", "maintenance", "rebuild-index"), - "_handle_rebuild_index", - ), - _StaticPostRoute( - "/api/maintenance/seal-canary-comparison", - ("api", "maintenance", "seal-canary-comparison"), - "_handle_seal_canary_comparison", - ), - _StaticPostRoute( - "/api/maintenance/consume-canary-report", - ("api", "maintenance", "consume-canary-report"), - "_handle_consume_canary_report", - ), - _StaticPostRoute( - "/api/maintenance/discard-index-candidate", - ("api", "maintenance", "discard-index-candidate"), - "_handle_discard_index_candidate", - ), ) @@ -1360,18 +1336,6 @@ def __bool__(self) -> bool: return self.allowed -# polylogue-ogn1: the write bridge's default run_sync/hold timeout (30s, -# DaemonWriteThreadBridge.__init__) is sized for ordinary request-scoped -# writes. A bounded rebuild-index pass is allowed to run far longer -- the -# CLI's own --daemon HTTP client already tolerates up to 600s -# (_rebuild_index.py's _run_daemon_rebuild, urlopen(..., timeout=600)) -- so -# the HTTP route asks the bridge to wait that same 600s instead of the 30s -# default. The bound returns control to the request path when parser or -# acceptance work stalls, rather than leaving the daemon writer request -# unbounded forever. -_REBUILD_INDEX_WRITE_TIMEOUT_S: float = 600.0 - - class DaemonAPIHandler(BaseHTTPRequestHandler): """HTTP handler for the daemon API server. @@ -2058,7 +2022,6 @@ def _do_post_impl(self) -> None: "_handle_mcp_call_log": "http.telemetry.mcp-call", "_handle_reset": "http.reset", "_handle_ingest": "http.ingest", - "_handle_maintenance_run": "http.maintenance.run", }.get(authenticated_route.handler_name) gate = self._write_gate(mutating_actor) if mutating_actor is not None else contextlib.nullcontext() with gate: @@ -5881,354 +5844,6 @@ def augment() -> None: ) self._send_json(HTTPStatus.OK, {"ok": True, "augmented": True, "overlays": with_overlays}) - @daemon_safe_handler - def _handle_maintenance_plan(self) -> None: - """POST /api/maintenance/plan — dry-run summary for maintenance targets.""" - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - body_text = body_raw.decode("utf-8") - try: - body = json.loads(body_text) - except json.JSONDecodeError: - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - raw_targets: list[str] = body.get("targets", []) - targets: tuple[str, ...] = tuple(str(t) for t in raw_targets) - - from polylogue.config import Config - from polylogue.maintenance.envelope import envelope_from_operation - from polylogue.maintenance.planner import preview_backfill - from polylogue.maintenance.scope import MaintenanceScopeFilter - from polylogue.paths import archive_root, render_root - - try: - scope_filter = MaintenanceScopeFilter.from_dict(_parse_scope_filter_body(body)) - except (TypeError, ValueError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - config = Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], - ) - result = preview_backfill(config, targets=targets, scope_filter=scope_filter) - envelope = envelope_from_operation(result, origin="daemon", mode="preview") - self._send_json(HTTPStatus.OK, envelope.to_dict()) - - @daemon_safe_handler - def _handle_maintenance_run(self) -> None: - """POST /api/maintenance/run — execute (or dry-run) maintenance.""" - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - body_text = body_raw.decode("utf-8") - try: - body = json.loads(body_text) - except json.JSONDecodeError: - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - raw_targets: list[str] = body.get("targets", []) - targets: tuple[str, ...] = tuple(str(t) for t in raw_targets) - dry_run: bool = bool(body.get("dry_run", False)) - - from polylogue.config import Config - from polylogue.core.enums import OperationStatus - from polylogue.maintenance.envelope import envelope_from_operation - from polylogue.maintenance.planner import execute_backfill - from polylogue.maintenance.scope import MaintenanceScopeFilter - from polylogue.paths import archive_root, render_root - - try: - scope_filter = MaintenanceScopeFilter.from_dict(_parse_scope_filter_body(body)) - except (TypeError, ValueError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - config = Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], - ) - result = execute_backfill(config, targets=targets, dry_run=dry_run, scope_filter=scope_filter) - envelope = envelope_from_operation(result, origin="daemon", mode="execute") - # A failed maintenance envelope is a semantic failure of this - # request, not a successful response describing a failure -- - # surface it as 422 so callers do not have to parse the body to - # notice (polylogue-71ey AC 4). - status = HTTPStatus.UNPROCESSABLE_ENTITY if result.status is OperationStatus.FAILED else HTTPStatus.OK - self._send_json(status, envelope.to_dict()) - - @daemon_safe_handler - def _handle_rebuild_index(self) -> None: - """POST /api/maintenance/rebuild-index — one coordinator-owned replay pass. - - The replay route has a finite bridge wait so an uncooperative parser or - acceptance check cannot leave the request path blocked indefinitely. - Canary receipt consumption remains a separate daemon-owned operation. - """ - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - try: - body = json.loads(body_raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if not isinstance(body, dict): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - raw_ids_value = body.get("raw_ids", []) - selected_session_ids = body.get("selected_session_ids", []) - candidate_acceptance_checks = body.get("candidate_acceptance_checks") - canary = body.get("canary", False) - if not isinstance(raw_ids_value, list) or not all( - isinstance(raw_id, str) and raw_id for raw_id in raw_ids_value - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if not isinstance(selected_session_ids, list) or not all( - isinstance(session_id, str) and session_id for session_id in selected_session_ids - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if candidate_acceptance_checks is not None and ( - not isinstance(candidate_acceptance_checks, list) - or not all(isinstance(check, str) and check for check in candidate_acceptance_checks) - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - only_missing = body.get("only_missing", False) - promote = body.get("promote", True) - max_blob_mb = body.get("max_blob_mb") - operation_id = body.get("operation_id") - schema_inference_receipt_path = body.get("schema_inference_receipt_path") - raw_batch_size = body.get("raw_batch_size", 500) - pass_byte_budget_mb = body.get("pass_byte_budget_mb") - pass_deadline_seconds = body.get("pass_deadline_seconds") - if not isinstance(only_missing, bool) or not isinstance(promote, bool) or not isinstance(canary, bool): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if max_blob_mb is not None and ( - isinstance(max_blob_mb, bool) or not isinstance(max_blob_mb, int | float) or max_blob_mb <= 0 - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if operation_id is not None and (not isinstance(operation_id, str) or not operation_id): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if schema_inference_receipt_path is not None and ( - not isinstance(schema_inference_receipt_path, str) or not schema_inference_receipt_path - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if isinstance(raw_batch_size, bool) or not isinstance(raw_batch_size, int): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if pass_byte_budget_mb is not None and ( - isinstance(pass_byte_budget_mb, bool) or not isinstance(pass_byte_budget_mb, int | float) - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if pass_deadline_seconds is not None and ( - isinstance(pass_deadline_seconds, bool) or not isinstance(pass_deadline_seconds, int | float) - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - rebuild_index_from_source_sync, - validate_rebuild_index_request, - ) - from polylogue.paths import archive_root - - request = RebuildIndexRequest( - archive_root=archive_root(), - only_missing=only_missing, - raw_ids=tuple(raw_ids_value), - selected_session_ids=tuple(selected_session_ids), - max_blob_mb=float(max_blob_mb) if max_blob_mb is not None else None, - promote=promote, - canary=canary, - candidate_acceptance_checks=( - tuple(candidate_acceptance_checks) if candidate_acceptance_checks is not None else None - ), - operation_id=operation_id, - schema_inference_receipt_path=( - Path(schema_inference_receipt_path) if schema_inference_receipt_path is not None else None - ), - raw_batch_size=raw_batch_size, - pass_byte_budget_mb=float(pass_byte_budget_mb) if pass_byte_budget_mb is not None else None, - pass_deadline_seconds=(float(pass_deadline_seconds) if pass_deadline_seconds is not None else None), - ) - try: - validate_rebuild_index_request(request) - except ValueError: - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - - bridge = getattr(self.server, "write_bridge", None) - if bridge is None: - # polylogue-ogn1: a real DaemonAPIHTTPServer always installs - # write_bridge in __init__ (either the caller's coordinator or an - # owned standalone one) -- this branch is never reachable there. - # Fail closed instead of running the rebuild directly outside the - # sole-writer coordinator: a route that can execute an authority- - # promoting archive write without ever holding the writer gate is - # a bypass of this daemon's single-writer invariant, not a safe - # fallback, even if nothing exercises it in production today. - self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable") - return - receipt = cast(DaemonWriteThreadBridge, bridge).run_sync_with_timeout( - "http.maintenance.rebuild-index", - _REBUILD_INDEX_WRITE_TIMEOUT_S, - rebuild_index_from_source_sync, - request, - ) - self._send_json(HTTPStatus.OK, receipt.to_dict()) - - @daemon_safe_handler - def _handle_discard_index_candidate(self) -> None: - """Discard one owned inactive index candidate through the daemon.""" - - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - try: - body = json.loads(body_raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - if not isinstance(body, dict): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - generation_id = body.get("generation_id") - generation_owner_id = body.get("generation_owner_id") - if ( - not isinstance(generation_id, str) - or not generation_id - or not isinstance(generation_owner_id, str) - or not generation_owner_id - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - from polylogue.maintenance.rebuild_index import discard_inactive_rebuild_candidate - from polylogue.paths import archive_root - - bridge = getattr(self.server, "write_bridge", None) - if bridge is None: - self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable") - return - cast(DaemonWriteThreadBridge, bridge).run_sync_with_timeout( - "http.maintenance.discard-index-candidate", - # Candidate cleanup must retain daemon ownership until completion: - # timing it out can orphan an inactive generation after a canary - # request has already released its receipt. - None, - discard_inactive_rebuild_candidate, - archive_root(), - generation_id, - generation_owner_id, - ) - self._send_json(HTTPStatus.OK, {"discarded": True, "generation_id": generation_id}) - - @daemon_safe_handler - def _handle_seal_canary_comparison(self) -> None: - """POST /api/maintenance/seal-canary-comparison through the daemon writer.""" - - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - try: - body = json.loads(body_raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - generation_id = body.get("generation_id") if isinstance(body, dict) else None - generation_owner_id = body.get("generation_owner_id") if isinstance(body, dict) else None - if ( - not isinstance(generation_id, str) - or not generation_id - or not isinstance(generation_owner_id, str) - or not generation_owner_id - ): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - from polylogue.maintenance.reindex_canary import ( - UnclassifiedCanaryDiffError, - seal_canary_comparison_under_daemon_ownership, - ) - from polylogue.paths import archive_root - - bridge = getattr(self.server, "write_bridge", None) - if bridge is None: - self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable") - return - try: - attestation = cast(DaemonWriteThreadBridge, bridge).run_sync_with_timeout( - "http.maintenance.seal-canary-comparison", - None, - seal_canary_comparison_under_daemon_ownership, - archive_root=archive_root(), - generation_id=generation_id, - generation_owner_id=generation_owner_id, - ) - except UnclassifiedCanaryDiffError as exc: - self._send_error(HTTPStatus.UNPROCESSABLE_ENTITY, "canary_comparison_invalid", str(exc)) - return - self._send_json(HTTPStatus.OK, attestation.to_dict()) - - @daemon_safe_handler - def _handle_consume_canary_report(self) -> None: - """POST /api/maintenance/consume-canary-report through the daemon writer.""" - - content_length = int(self.headers.get("Content-Length", 0)) - body_raw = self.rfile.read(content_length) if content_length > 0 else b"{}" - try: - body = json.loads(body_raw.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError): - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - report_path = body.get("report_path") if isinstance(body, dict) else None - if not isinstance(report_path, str) or not report_path: - self._send_error(HTTPStatus.BAD_REQUEST, "invalid_request") - return - from polylogue.maintenance.reindex_canary import ( - UnclassifiedCanaryDiffError, - approve_canary_report_under_daemon_ownership, - ) - from polylogue.paths import archive_root - - bridge = getattr(self.server, "write_bridge", None) - if bridge is None: - self._send_error(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable") - return - try: - payload = cast(DaemonWriteThreadBridge, bridge).run_sync_with_timeout( - "http.maintenance.consume-canary-report", - None, - approve_canary_report_under_daemon_ownership, - Path(report_path), - archive_root=archive_root(), - ) - except UnclassifiedCanaryDiffError as exc: - self._send_error(HTTPStatus.UNPROCESSABLE_ENTITY, "canary_report_invalid", str(exc)) - return - self._send_json(HTTPStatus.OK, payload) - - @daemon_safe_handler - def _handle_maintenance_status(self, operation_id: str) -> None: - """GET /api/maintenance/status/ — delegate to maintenance_registry_http.""" - from polylogue.daemon.maintenance_registry_http import handle_status - - handle_status(self, operation_id) - - @daemon_safe_handler - def _handle_maintenance_operations(self) -> None: - """GET /api/maintenance/operations — delegate to maintenance_registry_http.""" - from polylogue.daemon.maintenance_registry_http import handle_operations - - handle_operations(self) - # Bound for concurrent archive-query execution (polylogue-0hqs). ThreadingHTTPServer # spawns one raw OS thread per accepted connection with no cap; under sustained diff --git a/polylogue/daemon/maintenance_registry_http.py b/polylogue/daemon/maintenance_registry_http.py deleted file mode 100644 index c4ffdb5eeb..0000000000 --- a/polylogue/daemon/maintenance_registry_http.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Daemon HTTP handlers for the persistent maintenance operation registry (#1197). - -Lives outside :mod:`polylogue.daemon.http` so the main HTTP module stays -within its file-size budget. The registry endpoints are pure read -surfaces — they only consult the on-disk replay state directory under -``/.maintenance-state/`` — so they do not need access to -the daemon's runtime state. - -Two endpoints: - -* ``GET /api/maintenance/status/`` — one persisted operation - snapshot, wrapped in the shared - :class:`~polylogue.maintenance.envelope.MaintenanceOperationEnvelope` - plus state-file metadata (``updated_at``, ``state_path``); -* ``GET /api/maintenance/operations`` — every persisted snapshot under - a single ``{"operations": [...], "total": N}`` envelope. - -The handlers receive the live :class:`DaemonAPIHandler` instance and -use its :meth:`_send_json` / :meth:`_send_error` primitives so the -response shape and error semantics match the rest of the daemon API. -""" - -from __future__ import annotations - -from http import HTTPStatus -from typing import TYPE_CHECKING - -from polylogue.config import Config -from polylogue.maintenance.envelope import envelope_from_operation -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.maintenance.registry import MaintenanceOperationRegistry -from polylogue.paths import archive_root, render_root - -if TYPE_CHECKING: - from polylogue.daemon.http import DaemonAPIHandler - - -def _build_config() -> Config: - return Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], - ) - - -def handle_status(handler: DaemonAPIHandler, operation_id: str) -> None: - """GET /api/maintenance/status/ — one persisted operation snapshot.""" - try: - operation_id = validate_operation_id(operation_id) - except ValueError: - handler._send_error(HTTPStatus.BAD_REQUEST, "invalid_operation_id") - return - registry = MaintenanceOperationRegistry(config=_build_config()) - record = registry.get_operation(operation_id) - if record is None: - handler._send_error(HTTPStatus.NOT_FOUND, "not_found") - return - envelope = envelope_from_operation(record.operation, origin="daemon", mode="execute") - handler._send_json( - HTTPStatus.OK, - { - "envelope": envelope.to_dict(), - "updated_at": record.updated_at, - "state_path": str(record.state_path), - }, - ) - - -def handle_operations(handler: DaemonAPIHandler) -> None: - """GET /api/maintenance/operations — list every persisted operation snapshot.""" - registry = MaintenanceOperationRegistry(config=_build_config()) - records = registry.list_operations() - handler._send_json( - HTTPStatus.OK, - { - "operations": [ - { - "envelope": envelope_from_operation(r.operation, origin="daemon", mode="execute").to_dict(), - "updated_at": r.updated_at, - "state_path": str(r.state_path), - } - for r in records - ], - "total": len(records), - }, - ) - - -__all__ = ["handle_operations", "handle_status"] diff --git a/polylogue/daemon/metrics.py b/polylogue/daemon/metrics.py index 19d4b82109..262ab77552 100644 --- a/polylogue/daemon/metrics.py +++ b/polylogue/daemon/metrics.py @@ -104,7 +104,6 @@ ARCHIVE_LAYOUT_BLOCKER_LABELS, ARCHIVE_STORAGE_LAYOUTS, ) -from polylogue.storage.archive_readiness import active_rebuild_index_attempts from polylogue.storage.introspection import table_exists as _table_exists from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS @@ -1780,9 +1779,7 @@ def _emit_archive_storage_metrics(lines: list[str], db: Path, *, configured_root schema_mismatches=schema_mismatches, missing_backup_required=missing_backup_required, ) - active_rebuild_attempts = active_rebuild_index_attempts(configured_root / "ops.db") - materialization_blockers = ["active_rebuild_index"] if active_rebuild_attempts else [] - archive_ready = physical_archive_store and not blockers and not materialization_blockers + archive_ready = physical_archive_store and not blockers _emit_metric( lines, name="polylogue_archive_storage_layout", @@ -1798,16 +1795,9 @@ def _emit_archive_storage_metrics(lines: list[str], db: Path, *, configured_root samples=[ ({"state": "archive_runtime"}, 1 if archive_ready else 0), ({"state": "final_shape"}, 1 if final_shape_ready else 0), - ({"state": "materialized"}, 0 if materialization_blockers else 1), + ({"state": "materialized"}, 1), ], ) - _emit_metric( - lines, - name="polylogue_archive_rebuild_index_attempts", - help_text="Number of active rebuild-index maintenance attempts.", - metric_type="gauge", - samples=[(None, len(active_rebuild_attempts))], - ) _emit_metric( lines, name="polylogue_archive_active_store", diff --git a/polylogue/daemon/parse_prefetch.py b/polylogue/daemon/parse_prefetch.py index 9238276e84..d0bd673ad7 100644 --- a/polylogue/daemon/parse_prefetch.py +++ b/polylogue/daemon/parse_prefetch.py @@ -1,13 +1,6 @@ """Daemon-facing re-export of the shared off-writer-hold parse-stage engine. -polylogue-m6tp phase (a); relocated to substrate at -``polylogue.sources.census_parse_stage`` (polylogue-czq2) so the offline -rebuild engine (``maintenance/rebuild_index.py``) can consume the exact same -``CensusParseStage``/``RawParsePrefetchCache`` machinery this module used to -own exclusively, instead of only the daemon's own bulk-rebuild loop -(``daemon/bulk_rebuild.py``) ever getting a warmed prefetch cache while the -offline CLI and the daemon's own ``/api/maintenance/rebuild-index`` HTTP -route silently threaded ``prefetch_cache=None``. +The implementation lives in ``polylogue.sources.census_parse_stage``. Every name below is the SAME object as its ``polylogue.sources. census_parse_stage`` counterpart -- this module adds no behavior, only diff --git a/polylogue/daemon/route_contracts.py b/polylogue/daemon/route_contracts.py index 77545cf94a..942760c7dc 100644 --- a/polylogue/daemon/route_contracts.py +++ b/polylogue/daemon/route_contracts.py @@ -270,584 +270,6 @@ def route_contract_from_declaration(declaration: DaemonRouteDeclaration) -> Rout ROUTE_CONTRACTS: tuple[RouteContract, ...] = ( - RouteContract( - "GET", - "/", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic archive overview HTML", - "Canonical typed WebUI overview; browser data access remains behind the authenticated /api boundary.", - ), - RouteContract( - "GET", - "/app", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic archive overview HTML", - "SSR-first WebUI v2 strangler mount; Preact enhances only bounded continuation controls.", - ), - RouteContract( - "GET", - "/app/observability", - "browser_shell", - "shell_supported", - "credential_if_configured", - "semantic observability HTML", - "SSR-first registry and status projection; credentials protect embedded insight evidence when configured.", - ), - RouteContract( - "GET", - "/app/cost", - "browser_shell", - "shell_supported", - "credential_if_configured", - "semantic cost/usage HTML", - "SSR-first registry-driven cost rollup, usage timeline, and session drill-down; credentials protect embedded spend evidence when configured.", - ), - RouteContract( - "GET", - "/app/sessions", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic session list HTML", - "SSR-first origin/date/repo faceted session list; Preact enhances only bounded pagination.", - ), - RouteContract( - "GET", - "/app/sessions/:session_id", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic session read HTML", - "SSR-first session shell: header, lineage banner, and a simple message-flow placeholder Preact enhances with paging.", - ), - RouteContract( - "GET", - "/app/search", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic search results HTML", - "SSR-first ranked search over the shared SearchEnvelope; Preact enhances only cursor-based pagination.", - ), - RouteContract( - "GET", - "/app/assets/:asset", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "manifest-governed immutable Vite asset", - "Only content-hashed files named by the packaged Vite manifest are served.", - ), - RouteContract( - "GET", - "/s/:session_id", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "semantic session read HTML", - "Typed WebUI session deep-link; equivalent to /sessions/:session_id.", - ), - RouteContract( - "GET", - "/w/:mode", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "text/html web shell", - "Workspace shell bootstrap for registered workspace modes.", - ), - RouteContract( - "GET", - "/p", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "text/html paste browser", - "Standalone reader page; archive API calls remain authenticated.", - ), - RouteContract( - "GET", - "/a", - "browser_shell", - "shell_supported", - "unauthenticated_loopback", - "text/html attachment library", - "Standalone reader page; archive API calls remain authenticated.", - ), - RouteContract( - "GET", - "/healthz/live", - "operational", - "operational", - "unauthenticated_loopback", - "health liveness JSON", - "Unauthenticated for systemd/docker/kubernetes probes.", - ), - RouteContract( - "GET", - "/healthz/ready", - "operational", - "operational", - "unauthenticated_loopback", - "health readiness JSON", - "Unauthenticated for systemd/docker/kubernetes probes.", - ), - RouteContract( - "GET", - "/metrics", - "operational", - "operational", - "unauthenticated_loopback", - "Prometheus text exposition", - "Unauthenticated for Prometheus scrapers; no raw archive content.", - ), - RouteContract( - "POST", - "/api/web-auth/session", - "browser_shell", - "shell_supported", - "first_party_same_origin", - "WebCredentialBootstrapPayload", - "Rotates a scoped HttpOnly credential; no credential bytes appear in the response body.", - ), - RouteContract( - "DELETE", - "/api/web-auth/session", - "browser_shell", - "shell_supported", - "first_party_same_origin", - "WebCredentialRevocationPayload", - "Revokes the current first-party credential and expires its cookie.", - ), - RouteContract("GET", "/api/health/check", "operational", "stable", "credential_if_configured", "JSON"), - RouteContract("GET", "/api/health", "operational", "stable", "credential_if_configured", "JSON"), - route_contract_from_declaration(_STATUS_DECLARATION), - RouteContract( - "GET", - "/api/webui/observability", - "observability", - "shell_supported", - "credential_if_configured", - "WebUI observability projection", - "Registry descriptor fields, bounded rows, and the status-component snapshot adapter.", - ), - RouteContract( - "GET", - "/api/webui/freshness", - "observability", - "shell_supported", - "credential_if_configured", - "NamedSourceFreshness projection", - "Requires one explicit source path and rejects archive-wide scans.", - ), - RouteContract( - "GET", - "/api/overview", - "read_query", - "shell_supported", - "credential_if_configured", - "bounded cockpit overview", - "Privacy-safe landing aggregates, readiness, and a fixed recent-session page.", - ), - RouteContract("GET", "/api/events", "operational", "stable", "credential_if_configured", "SSE or JSON event poll"), - RouteContract( - "GET", - "/api/agents/coordination", - "operational", - "stable", - "credential_if_configured", - "AgentCoordinationPayload", - "Shared coordination envelope used by CLI, MCP, and the web mission-control projection.", - ), - route_contract_from_declaration(_FIND_DECLARATION), - RouteContract( - "POST", - "/api/cli/query", - "read_query", - "private", - "credential_if_configured", - "SearchEnvelope / SessionListResponse with route_state", - "Local UDS-only root-request parameter envelope; daemon owns query compilation.", - ), - RouteContract( - "POST", - "/api/operation", - "operational", - "private", - "credential_if_configured", - "DaemonOperationEnvelope", - "Local CLI/MCP control-plane transport for archive-scoped read operations.", - ), - RouteContract( - "POST", - "/api/cli/delete/prepare", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "delete preview envelope", - "Local CLI transport; validates a bounded exact selection before entering writer authority.", - ), - RouteContract( - "POST", - "/api/cli/delete/authorize", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "delete authorization envelope", - "Local CLI transport; issues one daemon-held authorization for an authenticated preview owner.", - ), - RouteContract( - "POST", - "/api/cli/delete/cancel", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "delete cancellation envelope", - "Local CLI transport; cancels an unconfirmed daemon-held preview under the writer gate.", - ), - RouteContract( - "POST", - "/api/cli/delete", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "MutationResultPayload", - "Local CLI transport; consumes one daemon-held authorization under the writer gate.", - ), - RouteContract( - "POST", - "/api/maintenance/rebuild-index", - "maintenance", - "operational", - "bearer_if_configured_and_same_origin", - "RebuildIndexReceipt", - "Runs exactly one source snapshot replay through the daemon write coordinator.", - ), - RouteContract( - "POST", - "/api/maintenance/seal-canary-comparison", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "sealed canary comparison attestation", - "Recomputes and immutably seals a candidate-owned historical comparison under the daemon writer gate.", - ), - RouteContract( - "POST", - "/api/maintenance/consume-canary-report", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "approved canary report", - "Validates report evidence while the daemon write coordinator owns the archive.", - ), - RouteContract( - "POST", - "/api/maintenance/discard-index-candidate", - "maintenance", - "private", - "bearer_if_configured_and_same_origin", - "inactive candidate discard receipt", - "Reclaims one generation only when its immutable owner id still identifies an inactive candidate.", - ), - RouteContract( - "GET", - "/api/facets", - "read_query", - "stable", - "credential_if_configured", - "FacetsResponse with route-state metadata", - "Repo and action facet families are deferred from first paint unless explicitly requested.", - ), - route_contract_from_declaration(_QUERY_UNITS_DECLARATION), - RouteContract( - "GET", - "/api/provider-usage", - "operational", - "stable", - "credential_if_configured", - "ProviderUsageReport", - "Usage-accounting diagnostics; separates provider events, cumulative counters, transcript words, and model rollups.", - ), - RouteContract( - "GET", - "/api/archive-debt", - "operational", - "stable", - "credential_if_configured", - "ArchiveDebtListPayload", - "Unified archive debt rows shared by CLI, Python API, MCP, and daemon clients.", - ), - RouteContract( - "GET", - "/api/import/explain", - "operational", - "shell_supported", - "credential_if_configured", - "ImportExplainPayload", - "Local import/source evidence explanation; paths are redacted unless explicitly requested.", - ), - RouteContract( - "GET", "/api/refs/resolve", "read_query", "stable", "credential_if_configured", "PublicRefResolutionPayload" - ), - RouteContract( - "GET", - "/api/query-completions", - "read_query", - "stable", - "credential_if_configured", - "query completion metadata", - ), - RouteContract( - "GET", - "/api/action-affordances", - "read_query", - "stable", - "credential_if_configured", - "ActionAffordanceListPayload", - "Shared query-action affordance inventory for CLI, daemon, and automation clients.", - ), - RouteContract( - "GET", - "/api/read-view-profiles", - "read_query", - "stable", - "credential_if_configured", - "read-view profile metadata", - ), - RouteContract( - "GET", - "/api/assertions", - "user_overlay", - "stable", - "credential_if_configured", - "AssertionClaimListPayload", - "Read-only assertion-backed overlay claims shared by the web workbench and API clients.", - ), - RouteContract( - "GET", "/api/sources", "read_detail", "shell_supported", "credential_if_configured", "source list JSON" - ), - RouteContract( - "GET", "/api/sessions/:id", "read_detail", "stable", "credential_if_configured", "Session detail JSON" - ), - RouteContract( - "GET", - "/api/sessions/:id/messages", - "read_detail", - "stable", - "credential_if_configured", - "session messages JSON", - ), - route_contract_from_declaration(_READ_DECLARATION), - RouteContract( - "GET", - "/api/sessions/:id/raw", - "read_detail", - "shell_supported", - "credential_if_configured", - "raw session payload JSON", - "Raw preview is opt-in and authenticated.", - ), - RouteContract( - "GET", "/api/sessions/:id/cost", "read_detail", "shell_supported", "credential_if_configured", "cost JSON" - ), - RouteContract( - "GET", - "/api/sessions/:id/evidence-summary", - "read_detail", - "shell_supported", - "credential_if_configured", - "bounded session evidence summary", - "Structural tool outcome counts, cost projection, and capped lineage refs for the transcript header.", - ), - RouteContract( - "GET", - "/api/sessions/:id/provenance", - "read_detail", - "stable", - "credential_if_configured", - "provenance envelope", - "Raw bytes require the include_raw query parameter.", - ), - RouteContract( - "GET", - "/api/sessions/:id/topology", - "read_detail", - "stable", - "credential_if_configured", - "topology envelope", - ), - RouteContract( - "GET", - "/api/sessions/:id/topology/parent-chain", - "read_detail", - "stable", - "credential_if_configured", - "parent-chain topology envelope", - ), - RouteContract( - "GET", - "/api/sessions/:id/similar", - "read_detail", - "stable", - "credential_if_configured", - "similar-session envelope", - ), - RouteContract( - "GET", - "/api/sessions/:id/attachments", - "read_detail", - "shell_supported", - "credential_if_configured", - "session attachment envelope", - ), - RouteContract( - "GET", - "/api/insights/sessions/:id", - "read_detail", - "stable", - "credential_if_configured", - "session insights envelope", - ), - RouteContract( - "GET", - "/api/webui/insights/:name", - "observability", - "shell_supported", - "credential_if_configured", - "Single WebUI insight descriptor projection", - "The daemon owns query construction and descriptor accessors; clients receive a bounded panel only.", - ), - RouteContract( - "GET", - "/api/raw_artifacts/:id", - "read_detail", - "shell_supported", - "credential_if_configured", - "raw artifact preview", - "Authenticated raw preview helper for the local shell.", - ), - RouteContract( - "GET", - "/api/thread-continue-templates", - "read_detail", - "shell_supported", - "credential_if_configured", - "thread continuation templates", - ), - RouteContract( - "GET", "/api/paste-browser", "read_query", "shell_supported", "credential_if_configured", "paste browser JSON" - ), - RouteContract( - "GET", - "/api/attachments", - "read_query", - "shell_supported", - "credential_if_configured", - "attachment library JSON", - ), - RouteContract( - "GET", "/api/stack", "workspace", "shell_supported", "credential_if_configured", "stack workspace JSON" - ), - RouteContract( - "GET", "/api/compare", "workspace", "shell_supported", "credential_if_configured", "compare workspace JSON" - ), - RouteContract("GET", "/api/user/marks", "user_overlay", "stable", "credential_if_configured", "marks JSON"), - RouteContract( - "GET", "/api/user/annotations", "user_overlay", "stable", "credential_if_configured", "annotations JSON" - ), - RouteContract( - "GET", "/api/user/annotations/:id", "user_overlay", "stable", "credential_if_configured", "annotation JSON" - ), - RouteContract( - "GET", "/api/user/saved-views", "user_overlay", "stable", "credential_if_configured", "saved views JSON" - ), - RouteContract( - "GET", "/api/user/saved-views/:id", "user_overlay", "stable", "credential_if_configured", "saved view JSON" - ), - RouteContract( - "GET", "/api/user/recall-packs", "user_overlay", "stable", "credential_if_configured", "recall packs JSON" - ), - RouteContract( - "GET", "/api/user/recall-packs/:id", "user_overlay", "stable", "credential_if_configured", "recall pack JSON" - ), - RouteContract( - "GET", "/api/user/workspaces", "user_overlay", "stable", "credential_if_configured", "workspaces JSON" - ), - RouteContract( - "GET", "/api/user/workspaces/:id", "user_overlay", "stable", "credential_if_configured", "workspace JSON" - ), - RouteContract( - "GET", - "/api/maintenance/operations", - "maintenance", - "stable", - "credential_if_configured", - "maintenance operations JSON", - ), - RouteContract( - "GET", - "/api/maintenance/status/:id", - "maintenance", - "stable", - "credential_if_configured", - "maintenance operation status JSON", - ), - RouteContract( - "POST", - "/api/telemetry/mcp-calls", - "operational", - "private", - "bearer_if_configured_and_same_origin", - "MCP call-log receipt", - "Machine-client telemetry; persisted by the daemon writer with bounded retention.", - ), - RouteContract( - "POST", - "/api/reset", - "maintenance", - "stable", - "bearer_if_configured_and_same_origin", - "reset result JSON", - ), - RouteContract( - "POST", - "/api/ingest", - "maintenance", - "stable", - "bearer_if_configured_and_same_origin", - "ingest result JSON", - ), - RouteContract( - "POST", - "/api/demo/augment", - "maintenance", - "operational", - "bearer_if_configured_and_same_origin", - "demo augmentation result JSON", - notes="Applies deterministic demo writes through the write bridge; " - "exists for the demo archive, not for general archive mutation.", - ), - RouteContract( - "POST", - "/api/maintenance/plan", - "maintenance", - "stable", - "bearer_if_configured_and_same_origin", - "maintenance operation preview", - ), - RouteContract( - "POST", - "/api/maintenance/run", - "maintenance", - "stable", - "bearer_if_configured_and_same_origin", - "maintenance operation result", - ), RouteContract( "POST", "/api/user/marks", "user_overlay", "stable", "credential_and_same_origin", "mutation envelope" ), diff --git a/polylogue/daemon/status.py b/polylogue/daemon/status.py index 7c08e735d2..588f83cb04 100644 --- a/polylogue/daemon/status.py +++ b/polylogue/daemon/status.py @@ -70,7 +70,6 @@ from polylogue.sources.live.watcher import default_sources from polylogue.storage.archive_identity import resolve_active_index_path from polylogue.storage.archive_readiness import ( - active_rebuild_index_attempts, probe_archive_tier, raw_materialization_readiness_snapshot, raw_materialization_ready, @@ -332,7 +331,6 @@ class ArchiveStorageStatus(BaseModel): archive_root_matches_configured: bool = True archive_ready: bool = False archive_materialization_ready: bool = False - active_rebuild_index_attempts: list[dict[str, object]] = Field(default_factory=list) final_shape_ready: bool = False archive_schema_ready: bool = False schema_mismatches: list[str] = Field(default_factory=list) @@ -701,13 +699,10 @@ def _archive_storage_info() -> ArchiveStorageStatus: missing_tiers = [str(tier.name) for tier in tiers if not tier.exists] index_exists = "index" in present_tiers source_exists = "source" in present_tiers - active_rebuild_attempts = active_rebuild_index_attempts(tier_paths["ops"]) final_shape_ready = not missing_tiers schema_mismatches = [str(tier.name) for tier in tiers if tier.exists and tier.version_status != "ok"] archive_schema_ready = final_shape_ready and not schema_mismatches - archive_ready = ( - index_exists and source_exists and archive_schema_ready and not active_rebuild_attempts and not conflicts - ) + archive_ready = index_exists and source_exists and archive_schema_ready and not conflicts if index_exists and source_exists: active_store: Literal["archive_file_set", "empty"] = "archive_file_set" else: @@ -720,7 +715,6 @@ def _archive_storage_info() -> ArchiveStorageStatus: archive_root_matches_configured=root == configured_root, archive_ready=archive_ready, archive_materialization_ready=archive_ready, - active_rebuild_index_attempts=active_rebuild_attempts, final_shape_ready=final_shape_ready, archive_schema_ready=archive_schema_ready, schema_mismatches=schema_mismatches, @@ -2012,13 +2006,10 @@ def _daemon_claim_guard( """Derive the claim-guard block for the daemon-serving status path.""" raw_component = _component_from_raw_materialization_readiness(raw_materialization_readiness) fts_component = _component_from_fts_readiness(fts_readiness) - rebuild_attempts = len(archive_storage.active_rebuild_index_attempts) - active_writer = bool(live_ingest_attempts.running_count) or bool(rebuild_attempts) + active_writer = bool(live_ingest_attempts.running_count) writer_parts: list[str] = [] if live_ingest_attempts.running_count: writer_parts.append(f"{live_ingest_attempts.running_count} live ingest attempt(s) running") - if rebuild_attempts: - writer_parts.append(f"{rebuild_attempts} index rebuild attempt(s) running") convergence_debt_pending = convergence.failed_count > 0 or convergence.deferred_count > 0 if not convergence.available: convergence_debt_summary = convergence.error or "convergence debt unavailable; convergence state is unknown" @@ -2184,8 +2175,6 @@ def _daemon_embedding_repair_hint( def _component_from_archive_storage(storage: ArchiveStorageStatus) -> ComponentReadiness: if storage.archive_ready: state = CapabilityReadinessState.READY - elif storage.active_rebuild_index_attempts: - state = CapabilityReadinessState.REBUILDING elif storage.final_shape_ready and storage.archive_schema_ready and not storage.archive_materialization_ready: state = CapabilityReadinessState.STALE elif storage.final_shape_ready or storage.schema_mismatches: @@ -2205,12 +2194,7 @@ def _component_from_archive_storage(storage: ArchiveStorageStatus) -> ComponentR caveats += ("materialization_pending",) repair_hint = None if state is not CapabilityReadinessState.READY: - if storage.schema_mismatches == ["index"]: - repair_hint = "polylogue ops maintenance rebuild-index" - elif storage.missing_tiers: - repair_hint = "polylogue ops maintenance archive-init --yes" - else: - repair_hint = "polylogued run" + repair_hint = "polylogue ops maintenance archive-init --yes" if storage.missing_tiers else "polylogued run" return ComponentReadiness( component="archive_storage", scope="archive", @@ -2223,7 +2207,6 @@ def _component_from_archive_storage(storage: ArchiveStorageStatus) -> ComponentR "final_shape_ready": storage.final_shape_ready, "archive_schema_ready": storage.archive_schema_ready, "schema_mismatch_count": len(storage.schema_mismatches), - "active_rebuild_index_attempt_count": len(storage.active_rebuild_index_attempts), }, caveats=caveats, repair_hint=repair_hint, diff --git a/polylogue/maintenance/rebuild_index.py b/polylogue/maintenance/rebuild_index.py deleted file mode 100644 index caebdf2c52..0000000000 --- a/polylogue/maintenance/rebuild_index.py +++ /dev/null @@ -1,2756 +0,0 @@ -"""Daemon-safe source-to-index rebuild execution. - -The operation owns the write-side rebuild protocol; CLI and HTTP are adapters. -Callers must hold the daemon writer coordinator for an online rebuild. The -offline guard rejects every other live-daemon caller, preserving break-glass -operation after the daemon has stopped. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import contextvars -import json -import os -import shutil -import sqlite3 -import time -from collections.abc import Iterable, Sequence -from dataclasses import asdict, dataclass, field, replace -from hashlib import sha256 -from http import HTTPStatus -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, cast - -from polylogue.config import Config -from polylogue.core.errors import PolylogueError -from polylogue.logging import get_logger -from polylogue.maintenance.offline_guard import offline_maintenance_block_reason -from polylogue.paths import render_root -from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation, assert_owns_archive_location -from polylogue.storage.fts.fts_lifecycle import rebuild_command_trigram_index_sync, rebuild_fts_index_sync -from polylogue.storage.fts.sql import FTS_REBUILD_SQL, TRIGRAM_REBUILD_DELETE_ALL_SQL -from polylogue.storage.introspection import table_exists -from polylogue.storage.sqlite.action_pairs import rebuild_all_action_pairs_sync -from polylogue.storage.sqlite.connection_profile import BULK_BUILD_WRITE_CONNECTION_PRAGMA_STATEMENTS -from polylogue.storage.sqlite.delegation_facts import rebuild_all_delegation_facts_sync - -if TYPE_CHECKING: - from polylogue.operations.candidate_build import CandidateBuildRequest - from polylogue.sources.revision_backfill import RawParsePrefetchCache - from polylogue.sources.source_snapshot import SourceCutResult - from polylogue.storage.index_generation import IndexGeneration, IndexGenerationStore, IndexRebuildTransaction - -_PLANNER_STATS_ANALYSIS_LIMIT = 1000 -# A fresh generation begins with representative bootstrap statistics, but a -# replay eventually needs measured selectivities as it grows. Refreshing -# after every resume page is needlessly expensive for small pages: ANALYZE -# must revisit a large set of indexes even when the generation changed by a -# fraction of a percent. Keep the measured statistics within one bounded -# source page of the materialized corpus instead. -_PLANNER_STATS_REFRESH_RAW_INTERVAL = 1000 -# Bulk-build replay keeps the FTS/trigram stores empty until final readiness, -# so analyzing their virtual-table backing stores does not improve any replay -# plan and can dominate a large archive's checkpoint. These row stores are -# the tables used by the writer-hot replacement/link/action-pair queries. -_PLANNER_STATS_ANALYZE_STATEMENTS = ( - "ANALYZE sessions", - "ANALYZE messages", - "ANALYZE blocks", - "ANALYZE session_links", - "ANALYZE action_pairs", -) - -logger = get_logger(__name__) - -_ACTIVE_EXTERNAL_INVENTORY_TOKEN: contextvars.ContextVar[dict[str, object] | None] = contextvars.ContextVar( - "rebuild_external_inventory_token", default=None -) - - -def candidate_build_schema_identity() -> tuple[int, tuple[str, ...]]: - """Return the daemon-owned source/index schema identity for candidates.""" - - from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER - from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - - source_version = ARCHIVE_VERSION_BY_TIER[ArchiveTier.SOURCE] - index_version = ARCHIVE_VERSION_BY_TIER[ArchiveTier.INDEX] - return source_version, (f"source:{source_version}", f"index:{index_version}") - - -def _candidate_source_cut_capacity(destination: Path) -> int: - return shutil.disk_usage(destination.parent).free - - -def freeze_candidate_source_inputs( - config: Config, *, destination: Path, request_id: str, fallback_source_path: Path -) -> SourceCutResult: - """Freeze configured acquisition roots before candidate construction.""" - from polylogue.maintenance.source_manifest_continuity import SourceDeclaration, SourceRole - from polylogue.pipeline.services.acquisition import AcquisitionService - from polylogue.sources.source_snapshot import SnapshotMode, SourceCutPolicy - - configured_inputs = [source.path for source in config.sources if source.path is not None] - capacity_bytes = _candidate_source_cut_capacity(destination) - declarations = [ - SourceDeclaration( - f"configured-{index}", - SourceRole.DIRECTORY if source.is_dir() else SourceRole.REWRITE_JSONL, - source, - True, - ) - for index, source in enumerate(configured_inputs) - ] - policies = { - declaration.source_id: SourceCutPolicy( - SnapshotMode.DIRECTORY_COPY if declaration.root.is_dir() else SnapshotMode.COMPLETE_COPY, - adapter_version="daemon-candidate-v1", - capacity_bytes=capacity_bytes, - ) - for declaration in declarations - } - if not declarations: - declarations = [SourceDeclaration("source-tier", SourceRole.MUTABLE_SQLITE, fallback_source_path, True)] - result: SourceCutResult = AcquisitionService.cut_source_inputs( - declarations, - destination, - request_id=request_id, - policies=policies, - ) - return result - - -def verify_frozen_candidate_source_inputs(destination: Path) -> SourceCutResult: - """Reload and hash-check the immutable inputs used by a candidate plan.""" - from polylogue.sources.source_snapshot import load_source_cut, reacquire_candidate - - result = load_source_cut(destination) - reacquire_candidate(result) - return result - - -class RebuildProvenanceError(RuntimeError): - """Raised when rebuild evidence is no longer valid for a mutation.""" - - -class RebuildDerivedStateProvenanceError(RebuildProvenanceError): - """A derived-state stage was blocked by a failed provenance recheck.""" - - -class RebuildSchemaCurrencyError(PolylogueError): - """The durable tiers do not match the package that would rebuild them.""" - - http_status_code = HTTPStatus.CONFLICT - - def __init__(self, diagnostic: dict[str, object]) -> None: - self.diagnostic = diagnostic - blocked = diagnostic["blocking_tiers"] - assert isinstance(blocked, list) - detail = ", ".join( - f"{item['tier']}.db:{item['actual_user_version']}!={item['expected_user_version']}" - for item in blocked - if isinstance(item, dict) - ) - super().__init__(f"rebuild schema currency preflight failed: {detail}") - - -def rebuild_schema_currency_preflight(root: Path) -> dict[str, object]: - """Report whether every durable tier matches this runtime package. - - ``index.db`` is intentionally absent: rebuilding it is the operation's - purpose, while a durable-tier mismatch means this package can interpret or - write durable evidence using a schema it does not own. - """ - from polylogue.storage.archive_readiness import probe_archive_tier - from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS - - checks: list[dict[str, object]] = [] - for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): - probe = probe_archive_tier(tier, root / f"{tier.value}.db") - checks.append( - { - "tier": tier.value, - "path": probe.path, - "actual_user_version": probe.user_version, - "expected_user_version": probe.expected_user_version, - "status": probe.version_status, - } - ) - blocking = [check for check in checks if check["status"] != "ok"] - return { - "kind": "rebuild-schema-currency", - "archive_root": str(root), - "status": "ready" if not blocking else "blocked", - "tiers": checks, - "blocking_tiers": blocking, - } - - -def require_rebuild_schema_currency(root: Path) -> dict[str, object]: - """Reject a rebuild before it consumes evidence or creates a generation.""" - diagnostic = rebuild_schema_currency_preflight(root) - if diagnostic["status"] != "ready": - raise RebuildSchemaCurrencyError(diagnostic) - return diagnostic - - -def validate_rebuild_source_admission(root: Path, location: ArchiveLocation) -> None: - """Validate frozen source authority under the owned archive identity.""" - from polylogue.sources.revision_backfill import validate_frozen_source_authority - - if location.configured_root != root.absolute(): - raise RuntimeError("rebuild source admission received a foreign archive location") - validate_frozen_source_authority(root) - - -@dataclass(slots=True) -class RebuildProvenanceContext: - """Validated evidence shared by every mutation in one rebuild pass. - - ``validate`` is the guard for operations that consume source evidence. - Cleanup has a narrower contract: it may remove only a generation that was - created under this already-validated context, and never reads or writes - source evidence. Keeping that distinction lets a failed receipt still - clean up non-promotable scratch generations without treating the stale - receipt as authorization to continue replaying. - """ - - root: Path - receipt_path: Path | None - source_snapshot: str - consumed_evidence: dict[str, object] - external_inventory_token: dict[str, object] = field(default_factory=dict) - verified_blob_integrity_snapshot: dict[str, object] | None = None - active_index_context: Literal["required", "unavailable_for_candidate"] = "required" - - def validate(self, *, verify_blob_integrity: bool = False, refresh_blob_integrity: bool = False) -> None: - cached_snapshot = None if refresh_blob_integrity else self.verified_blob_integrity_snapshot - validated = _validate_rebuild_provenance_receipt( - self.root, - self.receipt_path, - inventory_token=self.external_inventory_token, - verify_blob_integrity=verify_blob_integrity and self.active_index_context == "required", - verified_blob_integrity_snapshot=cached_snapshot, - ) - refreshed_inventory_token = validated.get("external_ground_truth_inventory_token") - if isinstance(refreshed_inventory_token, dict): - self.external_inventory_token = refreshed_inventory_token - self.consumed_evidence["external_ground_truth_inventory_token"] = refreshed_inventory_token - _ACTIVE_EXTERNAL_INVENTORY_TOKEN.set(refreshed_inventory_token) - if verify_blob_integrity: - refreshed_snapshot = validated.pop("_verified_blob_integrity_snapshot", None) - if isinstance(refreshed_snapshot, dict): - self.verified_blob_integrity_snapshot = refreshed_snapshot - from polylogue.maintenance.schema_inference_gate import rebuild_source_revision_snapshot - - if rebuild_source_revision_snapshot(self.root) != self.source_snapshot: - raise RebuildProvenanceError( - "rebuild schema-inference preflight gate failed: source evidence changed during replay" - ) - - def validate_cleanup(self) -> None: - """Authorize failure cleanup from the immutable pre-mutation proof.""" - if not self.consumed_evidence or not self.source_snapshot: - raise RuntimeError("rebuild cleanup has no validated provenance context") - - def receipt_evidence(self) -> dict[str, object]: - """Snapshot the latest receipt-bound evidence for a durable record.""" - return dict(self.consumed_evidence) - - -def _validate_rebuild_provenance_receipt( - root: Path, - receipt_path: Path | None, - *, - inventory_token: dict[str, object] | None = None, - verify_blob_integrity: bool = False, - verified_blob_integrity_snapshot: dict[str, object] | None = None, -) -> dict[str, object]: - """Validate rebuild provenance at the current ownership boundary.""" - from polylogue.maintenance.schema_inference_gate import ( - SchemaInferenceGateError, - validate_schema_inference_receipt, - ) - - if inventory_token is None: - inventory_token = _ACTIVE_EXTERNAL_INVENTORY_TOKEN.get() - try: - return validate_schema_inference_receipt( - root, - receipt_path, - inventory_token=inventory_token, - verify_blob_integrity=verify_blob_integrity, - verified_blob_integrity_snapshot=verified_blob_integrity_snapshot, - ) - except SchemaInferenceGateError as exc: - raise RebuildProvenanceError(f"rebuild schema-inference preflight gate failed: {exc}") from exc - - -_REBUILD_TERMINAL_NOT_RESUMABLE = frozenset({"promoted", "promoted-attestation-failed", "stale", "failed"}) - - -def _reconcile_active_generation_transaction( - store: IndexGenerationStore, transaction: IndexRebuildTransaction -) -> IndexRebuildTransaction: - """Turn a post-pointer-write transaction into terminal state on restart. - - Promotion changes the active pointer before the transaction attestation is - durable. If both the normal and recovery checkpoints fail, the persisted - transaction can still look resumable even though its generation is active. - The next resolver pass must record that observed fact before returning it - to the rebuild loop, otherwise the caller retries an already-active - generation forever. The transaction's generation owner is also required - to match the active generation owner, so a stale transaction cannot - attest a generation owned by another rebuild. - """ - - if transaction.status in _REBUILD_TERMINAL_NOT_RESUMABLE: - return transaction - try: - generation = store.load(transaction.generation_id) - active_path = store.active_pointer.resolve(strict=True) - generation_path = Path(generation.index_path).resolve(strict=True) - except (FileNotFoundError, OSError, TypeError, ValueError): - return transaction - if ( - generation.owner_id != transaction.generation_owner_id - or generation.state != "active" - or active_path != generation_path - ): - return transaction - return store.checkpoint_transaction( - transaction, - status="promoted-attestation-failed", - error="reconciled active generation after interrupted promotion attestation", - post_promotion_attestation={ - "status": "reconciled-after-restart", - "generation_id": generation.generation_id, - "generation_state": generation.state, - }, - ) - - -def _mark_rebuild_transaction_stale_after_provenance_failure( - root: Path, operation_id: str | None, error: RebuildProvenanceError -) -> None: - """Terminally classify a resumable pass whose next admission failed. - - The normal checkpoint helper validates first, which is correct for every - ordinary state transition. A failed source/receipt admission is the one - exception: re-running that validation would preserve a ``paused`` or - ``ready`` transaction indefinitely. The stale marker is lifecycle - evidence, so it is written directly through ``IndexGenerationStore`` and - never authorizes another replay. - """ - if operation_id is None: - return - from polylogue.storage.index_generation import IndexGenerationStore - - try: - store = IndexGenerationStore.for_archive_root(root, repair_anchor=False) - transaction = store.load_transaction(operation_id) - transaction = _reconcile_active_generation_transaction(store, transaction) - except Exception as load_error: - error.add_note(f"could not load rebuild transaction to mark it stale: {load_error}") - return - if transaction.status in {"promoted", "promoted-attestation-failed", "stale"}: - return - try: - store.checkpoint_transaction( - transaction, - status="stale", - error=f"stale because source evidence or receipt validation failed: {error}", - ) - except Exception as checkpoint_error: - error.add_note(f"could not persist stale rebuild transaction: {checkpoint_error}") - - -def _retire_empty_source_resume_transaction(root: Path, operation_id: str) -> None: - """Retire a resumable transaction when its source archive is now empty.""" - from polylogue.storage.index_generation import IndexGenerationStore - - store = IndexGenerationStore.for_archive_root(root, repair_anchor=False) - transaction = _reconcile_active_generation_transaction(store, store.load_transaction(operation_id)) - if transaction.status in {"promoted", "promoted-attestation-failed", "stale"}: - raise RuntimeError( - f"rebuild operation {transaction.operation_id} is {transaction.status}; start a new operation" - ) - store.checkpoint_transaction( - transaction, - status="stale", - error="rebuild source is empty; resumable transaction cannot continue", - ) - - -def _validate_before_derived_state( - provenance: RebuildProvenanceContext, - *, - verify_blob_integrity: bool = False, - refresh_blob_integrity: bool = False, -) -> None: - """Validate immediately before a derived-state mutation begins.""" - try: - provenance.validate( - verify_blob_integrity=verify_blob_integrity, - refresh_blob_integrity=refresh_blob_integrity, - ) - except Exception as exc: - raise RebuildDerivedStateProvenanceError(str(exc)) from exc - - -def _discard_generation_after_provenance_failure( - generation_store: IndexGenerationStore, generation: IndexGeneration, provenance: RebuildProvenanceContext -) -> list[BaseException]: - """Discard a fresh candidate without rereading mutable source evidence.""" - errors: list[BaseException] = [] - try: - provenance.validate_cleanup() - discarded = generation_store.discard_if_inactive(generation) - if not discarded: - errors.append(RuntimeError(f"candidate {generation.generation_id} was not discarded")) - except BaseException as exc: - errors.append(exc) - return errors - - -def discard_inactive_rebuild_candidate(archive_root: Path, generation_id: str, generation_owner_id: str) -> None: - """Discard exactly one daemon-owned inactive generation. - - The caller supplies both immutable generation identity fields. The daemon - invokes this under its writer coordinator after client-side canary - validation fails; CLI processes never perform this lifecycle mutation. - """ - - from polylogue.storage.archive_identity import ArchiveLocation - from polylogue.storage.index_generation import IndexGenerationStore - - store = IndexGenerationStore(ArchiveLocation.resolve(archive_root), repair_anchor=False) - candidate = store.load(generation_id) - if candidate.owner_id != generation_owner_id or candidate.state != "inactive": - raise RuntimeError(f"canary candidate {generation_id} is not an owned inactive generation") - if not store.discard_if_inactive(candidate): - raise RuntimeError(f"canary candidate {generation_id} was not discarded") - - -def _discard_transaction_after_provenance_failure( - generation_store: IndexGenerationStore, - transaction: IndexRebuildTransaction, - provenance: RebuildProvenanceContext, -) -> list[BaseException]: - """Discard a fresh candidate and transaction, attempting both independently.""" - errors: list[BaseException] = [] - try: - generation = generation_store.load(transaction.generation_id) - except FileNotFoundError: - generation = None - except BaseException as exc: - errors.append(exc) - generation = None - if generation is not None: - errors.extend(_discard_generation_after_provenance_failure(generation_store, generation, provenance)) - try: - discarded = generation_store.discard_transaction(transaction.operation_id) - if not discarded: - errors.append( - RuntimeError(f"transaction {transaction.operation_id} was not discarded because its record was missing") - ) - except BaseException as exc: - errors.append(exc) - return errors - - -def _cleanup_transaction_after_provenance_failure( - generation_store: IndexGenerationStore, - transaction: IndexRebuildTransaction, - root: Path, - receipt_path: Path | None, - consumed_evidence: dict[str, object], - primary: BaseException, -) -> None: - """Clean a fresh transaction and candidate while preserving its failure.""" - cleanup_context = RebuildProvenanceContext( - root=root, - receipt_path=receipt_path, - source_snapshot=str(consumed_evidence.get("source_snapshot", "")), - consumed_evidence=consumed_evidence, - ) - try: - cleanup_errors = _discard_transaction_after_provenance_failure(generation_store, transaction, cleanup_context) - except BaseException as cleanup_error: - cleanup_errors = [cleanup_error] - _add_cleanup_failure_notes(primary, cleanup_errors, label="rebuild transaction") - - -def _add_cleanup_failure_notes(primary: BaseException, errors: list[BaseException], *, label: str) -> None: - """Keep the primary exception while making cleanup failures visible.""" - if errors: - detail = "; ".join(f"{type(error).__name__}: {error}" for error in errors) - primary.add_note(f"{label} cleanup also failed: {detail}") - - -def _cleanup_nonresumable_generation_failure( - generation_store: IndexGenerationStore, - generation: IndexGeneration, - *, - root: Path, - receipt_path: Path | None, - consumed_evidence: dict[str, object], - primary: BaseException, -) -> None: - """Retire a one-shot candidate and expose every discard outcome.""" - cleanup_context = RebuildProvenanceContext( - root=root, - receipt_path=receipt_path, - source_snapshot=str(consumed_evidence.get("source_snapshot", "")), - consumed_evidence=consumed_evidence, - ) - try: - cleanup_errors = _discard_generation_after_provenance_failure(generation_store, generation, cleanup_context) - except BaseException as cleanup_error: - cleanup_errors = [cleanup_error] - _add_cleanup_failure_notes(primary, cleanup_errors, label="nonresumable rebuild") - - -def _create_rebuild_transaction_after_receipt_validation( - generation_store: IndexGenerationStore, - request: RebuildIndexRequest, - provenance: RebuildProvenanceContext, -) -> IndexRebuildTransaction: - """Create the first candidate only after an ownership-bound validation.""" - from polylogue.storage.index_generation import rebuild_source_evidence_snapshot - - provenance.validate() - source_snapshot = rebuild_source_evidence_snapshot(provenance.root) - transaction = generation_store.create_transaction( - source_snapshot=source_snapshot, - pass_byte_budget=( - int(request.pass_byte_budget_mb * 1024 * 1024) if request.pass_byte_budget_mb is not None else None - ), - pass_deadline_ms=( - int(request.pass_deadline_seconds * 1000) if request.pass_deadline_seconds is not None else None - ), - consumed_evidence=provenance.receipt_evidence(), - ) - try: - provenance.validate() - if rebuild_source_evidence_snapshot(provenance.root) != source_snapshot: - raise RebuildProvenanceError( - "rebuild schema-inference preflight gate failed: source evidence changed during transaction creation" - ) - except BaseException as exc: - _cleanup_transaction_after_provenance_failure( - generation_store, - transaction, - provenance.root, - provenance.receipt_path, - provenance.consumed_evidence, - exc, - ) - raise - return transaction - - -def _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store: IndexGenerationStore, - transaction: IndexRebuildTransaction, - provenance: RebuildProvenanceContext, - *, - status: str, - last_blob_hash_hex: str | None = None, - last_raw_id: str | None = None, - processed_raw_count: int | None = None, - processed_blob_bytes: int | None = None, - error: str | None = None, - derived_stores_cleared: bool | None = None, - post_promotion_attestation: dict[str, object] | None = None, - verify_blob_integrity: bool = False, -) -> IndexRebuildTransaction: - """Validate immediately before every persisted rebuild state transition.""" - provenance.validate(verify_blob_integrity=verify_blob_integrity) - return generation_store.checkpoint_transaction( - transaction, - status=status, - last_blob_hash_hex=last_blob_hash_hex, - last_raw_id=last_raw_id, - processed_raw_count=processed_raw_count, - processed_blob_bytes=processed_blob_bytes, - error=error, - derived_stores_cleared=derived_stores_cleared, - post_promotion_attestation=post_promotion_attestation, - consumed_evidence=provenance.receipt_evidence(), - ) - - -def _save_rebuild_pass_receipt_after_receipt_validation( - generation_store: IndexGenerationStore, - operation_id: str, - pass_receipt: RebuildIndexReceipt, - provenance: RebuildProvenanceContext, -) -> RebuildIndexReceipt: - """Validate before publishing a pass receipt tied to rebuild state.""" - provenance.validate() - pass_receipt = replace(pass_receipt, consumed_evidence=provenance.receipt_evidence()) - generation_store.save_pass_receipt(operation_id, pass_receipt.to_dict()) - return pass_receipt - - -#: Passed through to ``CensusParseStage.warm_raw_ids``'s ``max_payload_bytes`` -#: parameter. ``CensusParseStage`` treats it as the aggregate raw-payload -#: admission ceiling for one warm operation, independently of its longer-lived -#: cache ceiling, so a large rebuild cannot submit its entire raw set at once. -_OFFLINE_PREFETCH_WARM_MAX_PAYLOAD_BYTES = 512 * 1024 * 1024 - - -def _warm_offline_prefetch_cache(config: Config, raw_ids: list[str]) -> RawParsePrefetchCache | None: - """Pre-parse this pass's raw ids in a bounded thread pool before replay. - - polylogue-czq2: closes the gap where every rebuild caller EXCEPT the - daemon's own bulk-rebuild loop (``daemon/bulk_rebuild.py``, #3168) left - ``RebuildIndexRequest.prefetch_cache`` at its default ``None`` -- the - offline ``polylogue ops maintenance rebuild-index`` CLI and the daemon's - own ``/api/maintenance/rebuild-index`` HTTP route both construct a - ``RebuildIndexRequest`` without ever threading one, so ``census``'s parse - step (and the ``spill_load`` reload it feeds) always paid the full - unwarmed cost on those routes even though the exact machinery to avoid it - (``CensusParseStage``/``RawParsePrefetchCache``, - ``polylogue.sources.census_parse_stage``) already existed and was fully - wired through ``backfill_historical_revision_evidence``. - - Called from ``_rebuild_index_from_source_owned`` only when - ``request.prefetch_cache is None`` (a caller that already warmed its own - cache off a writer hold it does not yet hold -- the daemon's bulk-rebuild - loop -- is never overridden here). Returns ``None`` for an empty - ``raw_ids`` (nothing to warm); a construction/warm failure is never - raised -- this is a pure optimization over the unmodified parse path, so - any failure here must degrade to that path exactly like an ordinary - prefetch miss, never abort the rebuild pass itself. - """ - if not raw_ids: - return None - from polylogue.sources.census_parse_stage import CensusParseStage - - stage = CensusParseStage() - try: - warmed = stage.warm_raw_ids(config, raw_ids=raw_ids, max_payload_bytes=_OFFLINE_PREFETCH_WARM_MAX_PAYLOAD_BYTES) - logger.info( - "rebuild_index_offline_prefetch_warm", - requested=len(raw_ids), - warmed=warmed, - ) - except Exception: - logger.warning("rebuild_index_offline_prefetch_warm_failed", exc_info=True) - stage.shutdown() - return None - stage.shutdown() - return stage.cache - - -def _should_refresh_generation_planner_statistics( - *, - processed_before: int | None, - processed_after: int, -) -> bool: - """Return whether this replay pass crossed a measured-statistics boundary. - - Unbounded/one-shot rebuilds have no transaction cursor and always refresh - after replay. Resumable rebuilds retain their representative bootstrap - statistics until the first measured tranche is large enough, then refresh - whenever another bounded tranche has landed. This preserves writer-hot - query plans without making a 25 GiB generation pay an archive-wide - ANALYZE for every small recovery page. - """ - if processed_before is None: - return True - return processed_before // _PLANNER_STATS_REFRESH_RAW_INTERVAL < ( - processed_after // _PLANNER_STATS_REFRESH_RAW_INTERVAL - ) - - -def _open_bulk_build_maintenance_connection(index_path: Path, *, timeout: int) -> sqlite3.Connection: - """Open a terminal-stage maintenance connection with the bulk-build profile. - - The terminal repopulate/clear stages act on the same owned INACTIVE - generation the replay just bulk-wrote: never read until promoted, and - discarded wholesale if the pass raises. That is exactly the licence - ``BULK_BUILD_WRITE_CONNECTION_PROFILE`` documents (``journal_mode=MEMORY``, - ``synchronous=OFF``, large cache/mmap) -- previously these connections ran - with SQLite's stock defaults (rollback journal on disk, ``synchronous=FULL``, - ~2 MiB cache), which made the archive-wide FTS/trigram/action-pair - repopulate pay a full journal round-trip and fsync per committed batch at - full-table scale. - """ - conn = sqlite3.connect(index_path, timeout=timeout) - try: - for statement in BULK_BUILD_WRITE_CONNECTION_PRAGMA_STATEMENTS: - conn.execute(statement) - except BaseException: - conn.close() - raise - return conn - - -def _clear_bulk_build_derived_stores(index_path: Path) -> None: - """Idempotently empty ``messages_fts``/``blocks_command_trigram``. - - polylogue-v6i3: a fresh generation already starts with both derived - stores empty by construction (bootstrap creates empty schema), so the - very first pass of a brand-new bulk-build transaction never has - meaningful work to do here -- but calling this unconditionally on every - resumed pass (guarded by the transaction's own ``derived_stores_cleared`` - marker so it fires at most once per operation) converts "derived stores - are empty throughout bulk-build replay" from an assumption inherited from - generation creation into an explicit, verified invariant. This mirrors - the manual pre-promote recovery script's clearing action - (``/realm/tmp/trigram-restore-pre-promote.py``, the live incident this - bead productizes), now automatic. Delete-all on an already-empty table is - near-instant (28.7s was measured against a *populated* table during the - live incident this bead responds to; an empty one is orders of magnitude - faster), so this is cheap even when it turns out to be a no-op. - """ - with contextlib.closing(_open_bulk_build_maintenance_connection(index_path, timeout=60)) as conn: - conn.execute("PRAGMA busy_timeout = 60000") - if table_exists(conn, "messages_fts"): - conn.execute(FTS_REBUILD_SQL) - if table_exists(conn, "blocks_command_trigram"): - conn.execute(TRIGRAM_REBUILD_DELETE_ALL_SQL) - conn.commit() - - -def _repopulate_bulk_build_derived_state(index_path: Path) -> dict[str, float]: - """One archive-wide repopulate of every surface bulk-build replay skipped. - - polylogue-v6i3: ``write_parsed_session_to_archive``'s ``bulk_build`` mode - leaves ``messages_fts``, ``blocks_command_trigram``, ``action_pairs``, and - ``delegation_facts`` empty (or stale from a prior page) throughout replay - -- this runs exactly once, right before readiness, to bring all four back - into exact sync from ``blocks``/``messages``/``session_links`` in one bulk - delete+insert per surface instead of the per-session maintenance replay - skipped. Order matters: ``action_pairs`` must be repopulated before - ``delegation_facts`` (the latter's ``delegation_facts_source`` view joins - through the ``actions`` view, which reads ``action_pairs``). - """ - timings_s: dict[str, float] = {} - with contextlib.closing(_open_bulk_build_maintenance_connection(index_path, timeout=600)) as conn: - conn.execute("PRAGMA busy_timeout = 600000") - started_at = time.perf_counter() - rebuild_fts_index_sync(conn, resume_from_empty_message_index=True) - timings_s["fts"] = time.perf_counter() - started_at - started_at = time.perf_counter() - rebuild_command_trigram_index_sync(conn) - timings_s["command_trigram"] = time.perf_counter() - started_at - started_at = time.perf_counter() - rebuild_all_action_pairs_sync(conn) - timings_s["action_pairs"] = time.perf_counter() - started_at - started_at = time.perf_counter() - rebuild_all_delegation_facts_sync(conn) - timings_s["delegation_facts"] = time.perf_counter() - started_at - started_at = time.perf_counter() - conn.commit() - timings_s["commit"] = time.perf_counter() - started_at - return timings_s - - -def _refresh_generation_planner_statistics(index_path: Path) -> None: - """Replace bootstrap-seeded planner stats after a bounded replay tranche. - - A generation is bulk-written from empty, so the relative selectivities the - planner needs (session-scoped indexes are narrow, type-scoped ones are not) - drift fast as tables grow. Bounded periodic ANALYZE of only writer-hot row - stores keeps per-session plans (e.g. ``action_pairs`` refresh) on - session-scoped indexes; analyzing bulk-build's empty FTS virtual tables - adds archive-scale I/O without improving replay. Skipping measured row- - store statistics altogether reproduced an O(N^2) replay at >20x slower. - Failures are non-fatal: stale stats degrade speed, never correctness. - """ - try: - with contextlib.closing(_open_bulk_build_maintenance_connection(index_path, timeout=60)) as conn: - conn.execute(f"PRAGMA analysis_limit = {_PLANNER_STATS_ANALYSIS_LIMIT}") - for statement in _PLANNER_STATS_ANALYZE_STATEMENTS: - conn.execute(statement) - conn.commit() - except sqlite3.Error: - return - - -@dataclass(frozen=True, slots=True) -class RebuildIndexRequest: - """One bounded source snapshot replay request.""" - - archive_root: Path - only_missing: bool = False - raw_ids: tuple[str, ...] = () - # The canary's compared denominator. This is evidence-only: raw IDs remain - # the replay selection, while session IDs bind the later comparison scope. - selected_session_ids: tuple[str, ...] = () - max_blob_mb: float | None = None - promote: bool = True - # Candidate construction is capability-negative: the daemon supplies the - # canonical operation request, and this legacy execution adapter can only - # consume it as an inactive build. A boolean mode was intentionally - # removed: it carried no source/package/schema identity and made the - # broad rebuild request the accidental authority boundary. - candidate_operation: CandidateBuildRequest | None = None - # A daemon-owned mode, not a client-selected list of candidate checks. - canary: bool = False - candidate_acceptance_checks: tuple[str, ...] | None = None - operation_id: str | None = None - schema_inference_receipt_path: Path | None = None - raw_batch_size: int = 500 - pass_byte_budget_mb: float | None = None - pass_deadline_seconds: float | None = None - # polylogue-gd6v: in-process callers only (never CLI/HTTP wire params -- - # there is no JSON shape for a live cache object). Lets a caller that - # already computed parse output off a writer hold (the daemon's own - # ``CensusParseStage``, e.g. ``daemon/bulk_rebuild.py``) substitute it for - # this pass's census phase. Leaving this ``None`` (every CLI/HTTP-facing - # caller) does NOT skip prefetching any more: ``_rebuild_index_from_source_owned`` - # warms one itself for exactly this pass's selected raw ids before - # replaying (see ``_warm_offline_prefetch_cache``) -- a caller only needs - # to set this explicitly to reuse a cache warmed AHEAD of a writer hold it - # does not yet hold, which is what the daemon's bulk-rebuild loop does. - prefetch_cache: RawParsePrefetchCache | None = None - # polylogue-pzxm: split this pass's selected raw ids into shard_count - # owned-inactive generations built in parallel (free-threaded - # interpreter: threads share parsed graphs), merged sequentially into - # this pass's real target generation before the existing terminal - # stages run. 1 (the default) is the unchanged single-writer path -- - # every existing caller (CLI, daemon HTTP route, bulk-rebuild loop) is - # unaffected until it opts in. See - # polylogue.maintenance.sharded_rebuild for the merge/graph-resolution - # correctness argument. - shard_count: int = 1 - - -@dataclass(frozen=True, slots=True) -class RebuildPassCost: - """What one rebuild pass cost, and what that implies for the whole run. - - Three full rebuilds completed with no cost breakdown persisted anywhere. - The only forensics available afterwards was receipt file mtimes -- enough - to show 88% of a 74-hour run was idle wall-clock, but not enough to say - where the remaining 9.2 hours of compute went. - - ``replay_s`` / ``checkpoint_s`` where the pass went. ``replay_s`` is the - wall-clock time around the whole ``replay_source`` call (parse + - apply + the small async-dispatch overhead between them); ``parse_s`` - and ``apply_s`` (below) are its breakdown. - ``parse_s`` / ``apply_s`` the parse-vs-apply split (polylogue-623q). - ``parse_s`` is read-only decode (census parse + spill-cache reload of - already-parsed content) -- embarrassingly parallel, scales with - ``parse_workers``. ``apply_s`` is everything charged to the single - SQLite writer (index/FTS/projection writes) -- serialized, does not - scale with worker count. Sourced from - ``revision_backfill.split_parse_and_apply_seconds`` over the - ``stage_timings_s`` dict threaded back through ``replay_source``'s - return value; before that threading existed, ``replay_s`` was the - only number recorded and there was no way to tell decode and writer - cost apart. Both are ``0.0`` if the pass replayed zero raws (no - stage ever ran). - ``mib_per_s`` / ``raws_per_s`` is throughput holding, or degrading as the - index grows? - ``free_threaded`` / ``parse_workers`` did parallel parse actually engage? - A GIL build silently parses ~98.5% of this corpus' bytes on ONE core, - which is exactly how a 9-hour rebuild happened. That belongs in the - durable artifact, not only a log line read afterwards. - ``percent_bytes`` / ``eta_s`` how far in and how long left, from THIS - run's observed byte rate. Progress is in BYTES because cost is - bytes-bound -- passes end ``deferred`` on a byte budget, so a row-count - percentage would call a rebuild half done with most of the payload left. - """ - - replay_s: float - checkpoint_s: float - pass_s: float - raws: int - bytes_in: int - processed_raws: int - processed_bytes: int - total_raws: int - total_bytes: int - free_threaded: bool - parse_workers: int - parse_s: float = 0.0 - apply_s: float = 0.0 - #: polylogue-6mvg: wall-clock seconds this pass spent choosing WHICH raws - #: to replay -- the resumable path's ``next_raw_page`` keyset query, or - #: the one-shot path's ``select_rebuild_raw_ids`` (full-source/only- - #: missing/max-blob-mb scan plus size filter) -- before any census/parse/ - #: apply work started. Previously invisible: a live full rebuild spent - #: ~86s of one CPU core on selection alone before the inactive generation - #: held a single session, with nothing durable recording where that time - #: went. - selection_s: float = 0.0 - #: Time spent classifying byte and membership authority cohorts. This is - #: a diagnostic rollup over the replay stage ledger, retained separately - #: from the parse/apply split for phase-level receipts. - cohort_s: float = 0.0 - #: Time spent warming this pass's bounded offline parse cache before - #: replay. This work used to sit outside both ``replay_s`` and ``pass_s``, - #: leaving a potentially substantial decode phase unexplained in a - #: full-corpus receipt. A caller-provided cache was warmed before this - #: function, so it truthfully records ``0.0`` here. - prefetch_warm_s: float = 0.0 - #: Terminal insight materialization time. Deferred/paused passes carry - #: the explicit zero because they have not reached terminal stages. - insight_s: float = 0.0 - #: Sum of terminal stages after replay, excluding raw selection. - terminal_s: float = 0.0 - - @property - def mib_per_s(self) -> float: - return (self.bytes_in / (1024 * 1024) / self.pass_s) if self.pass_s > 0 else 0.0 - - @property - def raws_per_s(self) -> float: - return (self.raws / self.pass_s) if self.pass_s > 0 else 0.0 - - @property - def remaining_bytes(self) -> int: - return max(0, self.total_bytes - self.processed_bytes) - - @property - def eta_s(self) -> float | None: - """Seconds remaining at this pass's observed byte rate, or None.""" - if self.pass_s <= 0 or self.bytes_in <= 0 or self.total_bytes <= 0: - return None - return self.remaining_bytes / (self.bytes_in / self.pass_s) - - def to_dict(self) -> dict[str, object]: - eta = self.eta_s - return { - "selection_s": round(self.selection_s, 3), - "cohort_s": round(self.cohort_s, 3), - "prefetch_warm_s": round(self.prefetch_warm_s, 3), - "replay_s": round(self.replay_s, 3), - "parse_s": round(self.parse_s, 3), - "apply_s": round(self.apply_s, 3), - "insight_s": round(self.insight_s, 3), - "terminal_s": round(self.terminal_s, 3), - "checkpoint_s": round(self.checkpoint_s, 3), - "pass_s": round(self.pass_s, 3), - "raws": self.raws, - "bytes_in": self.bytes_in, - "mib_per_s": round(self.mib_per_s, 2), - "raws_per_s": round(self.raws_per_s, 2), - "processed_raws": self.processed_raws, - "processed_bytes": self.processed_bytes, - "total_raws": self.total_raws, - "total_bytes": self.total_bytes, - "percent_bytes": round(100.0 * self.processed_bytes / self.total_bytes, 2) if self.total_bytes else 0.0, - "eta_s": round(eta, 1) if eta is not None else None, - "free_threaded": self.free_threaded, - "parse_workers": self.parse_workers, - } - - -_COHORT_TIMING_PREFIXES = ("replay.classify_cohort", "replay.adoptable_check", "membership.") - - -def _cohort_seconds(stage_timings_s: object) -> float: - """Roll up the durable replay timing keys that decide authority cohorts.""" - if not isinstance(stage_timings_s, dict): - return 0.0 - return sum( - float(value) - for key, value in stage_timings_s.items() - if isinstance(key, str) - and key.startswith(_COHORT_TIMING_PREFIXES) - and isinstance(value, int | float) - and not isinstance(value, bool) - ) - - -def _receipt_timings( - *, - rebuild_s: float, - selection_s: float, - prefetch_warm_s: float, - replay: dict[str, object], - terminal_timings_s: dict[str, float], -) -> dict[str, float]: - """Build one stable phase vocabulary plus existing granular timings. - - ``rebuild_s`` starts before source preflight and ownership acquisition, then - ends immediately before the immutable receipt is persisted. ``orchestration_s`` - names the remainder spent on those admission, candidate-lifecycle, and - receipt-preparation steps, so the end-to-end number remains attributable. - """ - stage_timings_s = replay.get("stage_timings_s", {}) - replay_s = stage_timings_s.get("total", 0.0) if isinstance(stage_timings_s, dict) else 0.0 - parse_s = replay.get("parse_s", 0.0) - apply_s = replay.get("apply_s", 0.0) - resolved_replay_s = float(replay_s) if isinstance(replay_s, int | float) and not isinstance(replay_s, bool) else 0.0 - resolved_parse_s = float(parse_s) if isinstance(parse_s, int | float) and not isinstance(parse_s, bool) else 0.0 - resolved_apply_s = float(apply_s) if isinstance(apply_s, int | float) and not isinstance(apply_s, bool) else 0.0 - insight_s = float(terminal_timings_s.get("terminal.session_insights", 0.0)) - terminal_s = sum(float(value) for key, value in terminal_timings_s.items() if key != "selection_s") - resolved_rebuild_s = max(0.0, float(rebuild_s)) - orchestration_s = max(0.0, resolved_rebuild_s - selection_s - prefetch_warm_s - resolved_replay_s - terminal_s) - rollups = { - "rebuild_s": resolved_rebuild_s, - "orchestration_s": orchestration_s, - "selection_s": float(selection_s), - "prefetch_warm_s": float(prefetch_warm_s), - "cohort_s": _cohort_seconds(stage_timings_s), - "replay_s": resolved_replay_s, - "parse_s": resolved_parse_s, - "apply_s": resolved_apply_s, - "insight_s": insight_s, - "terminal_s": terminal_s, - } - return { - **{key: round(value, 3) for key, value in rollups.items()}, - **{key: round(float(value), 3) for key, value in terminal_timings_s.items()}, - } - - -@dataclass(frozen=True, slots=True) -class RebuildIndexReceipt: - """Typed evidence emitted after one source-to-index rebuild pass.""" - - archive_root: str - raw_session_count: int - selected_raw_count: int - skipped_by_blob_limit_count: int - status: str - materialized: bool - materialization: dict[str, object] - generation: dict[str, object] - readiness: dict[str, object] - replay: dict[str, object] - transaction: dict[str, object] | None = None - # Explicit operation evidence, retained in every pass receipt as well as - # returned to CLI/HTTP callers. ``transaction`` remains the backwards - # compatible full checkpoint payload; this compact view is the stable - # operator contract for ownership, heartbeat, cursor, delta, and recovery. - operation: dict[str, object] = field(default_factory=dict) - # Rebuild-owned evidence for the exact raw selection replayed into this - # candidate. The IDs themselves are deliberately not duplicated in every - # receipt; the stable commitment is enough for a verifier holding the - # requested IDs to prove the set, count, source snapshot, and candidate - # identity all agree. - selection_evidence: dict[str, object] = field(default_factory=dict) - # The immutable source-evidence hash taken after replay. This is separate - # from the generation's before-replay source_snapshot so report - # consumption can reject source drift without treating parser or - # governance state as part of the canary's identity. - source_evidence_after: str | None = None - # Present for daemon-selected canary candidates. It binds the canonical - # profile identity to every executed check result. - canary_acceptance: dict[str, object] | None = None - #: Wall-clock seconds per rebuild stage for THIS pass. - #: - #: Three full rebuilds ran without this, so the only cost breakdown - #: available afterwards was receipt file mtimes -- enough to show 88% of a - #: 74h run was idle, but not enough to say where the remaining 9.2h of - #: compute went. The terminal stages were already logged as structured - #: events; logs are not the durable artifact and per-pass parse/apply was - #: not measured at all. Persisting it here makes the next optimisation - #: evidence-based rather than a guess. - timings_s: dict[str, float] = field(default_factory=dict) - # Aggregate-only shape of the durable source plus this candidate index. - # Stored on every receipt shape (including bounded passes), so timing - # evidence is attributable even when a full replay spans many passes. - corpus_shape: dict[str, int] = field(default_factory=dict) - consumed_evidence: dict[str, object] = field(default_factory=dict) - - def to_dict(self) -> dict[str, object]: - return { - "receipt_schema_version": 5, - "archive_root": self.archive_root, - "raw_session_count": self.raw_session_count, - "selected_raw_count": self.selected_raw_count, - "skipped_by_blob_limit_count": self.skipped_by_blob_limit_count, - "status": self.status, - "materialized": self.materialized, - "materialization": self.materialization, - "generation": self.generation, - "readiness": self.readiness, - "transaction": self.transaction, - "operation": self.operation, - "selection_evidence": self.selection_evidence, - "source_evidence_after": self.source_evidence_after, - "canary_acceptance": self.canary_acceptance, - "timings_s": self.timings_s, - "corpus_shape": self.corpus_shape, - "consumed_evidence": self.consumed_evidence, - **self.replay, - } - - -def rebuild_selection_evidence( - raw_ids: list[str] | tuple[str, ...], - *, - archive_root: Path, - generation_id: str, - generation_owner_id: str, - candidate_index: Path, - source_snapshot: str, - selected_session_ids: list[str] | tuple[str, ...] = (), -) -> dict[str, object]: - """Commit the requested and production-expanded replay closure. - - The replay path can widen a raw-id hint after census discovers durable - membership and logical-source-key relationships. Persisting only the - caller's hints would let a later source mutation change which raws and - cohorts the candidate actually represents without invalidating its - receipt. - """ - - replay_closure = _rebuild_replay_closure_evidence(archive_root, raw_ids) - - canonical = { - "archive_root": str(archive_root.resolve()), - "candidate_generation_id": generation_id, - "candidate_index_path": str(candidate_index.resolve()), - "candidate_owner_id": generation_owner_id, - "raw_ids": sorted(raw_ids), - "selected_session_ids": sorted(selected_session_ids), - "replay_closure": replay_closure, - "source_snapshot": source_snapshot, - } - encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") - return { - "algorithm": "sha256-canonical-json-v1", - "raw_id_count": len(raw_ids), - "selected_session_count": len(selected_session_ids), - "selected_session_ids": sorted(selected_session_ids), - "raw_ids_sha256": sha256(encoded).hexdigest(), - **{key: value for key, value in canonical.items() if key != "raw_ids"}, - } - - -def _rebuild_replay_closure_evidence(archive_root: Path, raw_ids: list[str] | tuple[str, ...]) -> dict[str, object]: - """Read the same durable closure primitive used by source replay. - - Missing source tiers are tolerated for standalone structural tests that - exercise selection serialization without an archive. Real rebuilds always - have ``source.db`` and therefore record the full expanded closure and every - membership row participating in it. - """ - - source_db = archive_root / "source.db" - if not source_db.exists(): - return { - "raw_ids": sorted(raw_ids), - "logical_source_keys": [], - "raw_session_evidence": [], - "raw_session_memberships": [], - } - - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - bind_chunk_size = 900 - - def chunks(values: Sequence[str]) -> Iterable[list[str]]: - for start in range(0, len(values), bind_chunk_size): - yield list(values[start : start + bind_chunk_size]) - - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as connection: - expanded, logical_keys = ArchiveStore.expand_raw_membership_selection_sync(connection, list(raw_ids)) - membership_rows: dict[tuple[str, str], tuple[Any, ...]] = {} - membership_columns = ( - "raw_id, logical_source_key, provider_session_id, source_revision, " - "normalized_content_hash, message_count, predecessor_raw_id, acquisition_generation, " - "revision_authority, decision, decided_at_ms" - ) - for column, values in (("raw_id", expanded), ("logical_source_key", logical_keys)): - for batch in chunks(values): - placeholders = ",".join("?" for _ in batch) - selected = connection.execute( - f"SELECT {membership_columns} FROM raw_session_memberships WHERE {column} IN ({placeholders})", - batch, - ) - for row in selected: - membership_rows[(str(row[0]), str(row[1]))] = tuple(row) - - rows: list[dict[str, object]] = [] - for row in sorted(membership_rows.values(), key=lambda item: (str(item[1]), str(item[0]))): - rows.append( - { - "raw_id": str(row[0]), - "logical_source_key": str(row[1]), - "provider_session_id": str(row[2]), - "source_revision": str(row[3]), - "normalized_content_hash": bytes(row[4]).hex(), - "message_count": int(row[5]), - "predecessor_raw_id": None if row[6] is None else str(row[6]), - "acquisition_generation": int(row[7]), - "revision_authority": str(row[8]), - "decision": None if row[9] is None else str(row[9]), - "decided_at_ms": None if row[10] is None else int(row[10]), - } - ) - - raw_rows: list[tuple[Any, ...]] = [] - for batch in chunks(expanded): - placeholders = ",".join("?" for _ in batch) - raw_rows.extend( - tuple(row) - for row in connection.execute( - f""" - SELECT raw_id, origin, capture_mode, native_id, source_path, - source_index, blob_hash, blob_size, acquired_at_ms, - logical_source_key, revision_kind, source_revision, - predecessor_source_revision, predecessor_raw_id, - baseline_raw_id, append_start_offset, append_end_offset, - acquisition_generation, revision_authority, - revision_authority_evidence - FROM raw_sessions WHERE raw_id IN ({placeholders}) - """, - batch, - ).fetchall() - ) - raw_rows.sort(key=lambda row: str(row[0])) - from polylogue.sources.origin_specs import ( - lowering_fingerprint, - materializer_fingerprint, - parser_fingerprint_for_origin, - replay_routing_fingerprint, - ) - - lowering = lowering_fingerprint() - materializer = materializer_fingerprint() - replay_routing = replay_routing_fingerprint() - parser_fingerprints: dict[str, str] = {} - for row in raw_rows: - origin = str(row[1]) - if origin not in parser_fingerprints: - parser_fingerprints[origin] = parser_fingerprint_for_origin(origin) - - raw_evidence = [ - { - "raw_id": str(row[0]), - "origin": str(row[1]), - "capture_mode": None if row[2] is None else str(row[2]), - "native_id": None if row[3] is None else str(row[3]), - "source_path": str(row[4]), - "source_index": int(row[5]), - "blob_hash": bytes(row[6]).hex(), - "blob_size": int(row[7]), - "acquired_at_ms": int(row[8]), - "logical_source_key": None if row[9] is None else str(row[9]), - "revision_kind": str(row[10]), - "source_revision": None if row[11] is None else str(row[11]), - "predecessor_source_revision": None if row[12] is None else str(row[12]), - "predecessor_raw_id": None if row[13] is None else str(row[13]), - "baseline_raw_id": None if row[14] is None else str(row[14]), - "append_start_offset": None if row[15] is None else int(row[15]), - "append_end_offset": None if row[16] is None else int(row[16]), - "acquisition_generation": None if row[17] is None else int(row[17]), - "revision_authority": str(row[18]), - "revision_authority_evidence": None if row[19] is None else str(row[19]), - "parser_fingerprint": parser_fingerprints[str(row[1])], - "lowering_fingerprint": lowering, - "replay_routing_fingerprint": replay_routing, - "materializer_fingerprint": materializer, - } - for row in raw_rows - ] - return { - "raw_ids": list(expanded), - "logical_source_keys": list(logical_keys), - "raw_session_evidence": raw_evidence, - "raw_session_memberships": rows, - } - - -def _persist_candidate_receipt(generation: IndexGeneration, receipt: dict[str, object]) -> None: - """Atomically retain the completed rebuild receipt with its candidate.""" - - path = Path(generation.index_path).parent / "rebuild-receipt.json" - temporary = path.with_suffix(".json.tmp") - try: - with temporary.open("w", encoding="utf-8") as stream: - json.dump(receipt, stream, ensure_ascii=False, indent=2, sort_keys=True) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) - finally: - if temporary.exists(): - temporary.unlink() - - -def _operation_evidence( - root: Path, - *, - generation: IndexGeneration | None, - transaction: IndexRebuildTransaction | None, - recovery_state: str, -) -> dict[str, object]: - """Return the compact, durable operation receipt shared by every pass. - - This is intentionally assembled from the existing archive-root lease and - transaction checkpoint. It adds no competing ownership mechanism and - keeps the full checkpoint in ``transaction`` for callers that need it. - """ - from polylogue.storage.index_generation import rebuild_lease_status, rebuild_source_evidence_snapshot - - transaction_payload = asdict(transaction) if transaction is not None else {} - generation_payload = asdict(generation) if generation is not None else {} - source_snapshot = transaction_payload.get("source_snapshot") or generation_payload.get("source_snapshot") - current_snapshot = rebuild_source_evidence_snapshot(root) if (root / "source.db").exists() else None - return { - "owner": { - "generation_owner_id": transaction_payload.get("generation_owner_id") or generation_payload.get("owner_id"), - "pid": transaction_payload.get("owner_pid"), - "host": transaction_payload.get("owner_host"), - "lease": rebuild_lease_status(root).to_dict(), - }, - "generation": { - "generation_id": generation_payload.get("generation_id"), - "state": generation_payload.get("state"), - }, - "heartbeat": {"at_ms": transaction_payload.get("heartbeat_at_ms")}, - "cursor": transaction.cursor if transaction is not None else None, - "delta": { - "source_snapshot_matches": current_snapshot == source_snapshot if current_snapshot is not None else None, - "current_source_snapshot": current_snapshot, - "transaction_source_snapshot": source_snapshot, - }, - "recovery_state": recovery_state, - } - - -def validate_rebuild_index_request(request: RebuildIndexRequest) -> None: - """Reject selection and transaction combinations that cannot be promoted safely.""" - if request.candidate_operation is not None: - if request.promote: - raise ValueError("candidate builds require --no-promote and cannot accept or replace the active index") - if request.raw_ids or request.only_missing or request.max_blob_mb is not None: - raise ValueError("candidate build selection is daemon-owned") - if request.candidate_acceptance_checks is not None: - raise ValueError("candidate build acceptance profile is daemon-owned") - if request.raw_ids and request.only_missing: - raise ValueError("--raw-id cannot be combined with --only-missing") - if request.selected_session_ids and not request.raw_ids: - raise ValueError("selected session evidence requires an explicit raw-id selection") - if len(set(request.selected_session_ids)) != len(request.selected_session_ids) or not all( - request.selected_session_ids - ): - raise ValueError("selected session evidence must contain unique non-empty ids") - if (request.raw_ids or request.only_missing) and request.promote: - raise ValueError("partial rebuild selections require --no-promote and can never replace the active index") - if request.promote and request.candidate_acceptance_checks is not None: - raise ValueError("caller-supplied candidate acceptance profiles require --no-promote") - if request.canary and request.promote: - raise ValueError("canary rebuilds require --no-promote") - if request.canary and request.candidate_acceptance_checks is not None: - raise ValueError("canary acceptance profile is daemon-owned") - if request.max_blob_mb is not None and request.max_blob_mb <= 0: - raise ValueError("max blob size must be positive") - if request.max_blob_mb is not None and not request.raw_ids and not request.only_missing: - raise ValueError("--max-blob-mb requires --only-missing or --raw-id") - if request.raw_batch_size <= 0: - raise ValueError("raw batch size must be positive") - if request.pass_byte_budget_mb is not None and request.pass_byte_budget_mb <= 0: - raise ValueError("pass byte budget must be positive") - if request.pass_deadline_seconds is not None and request.pass_deadline_seconds <= 0: - raise ValueError("pass deadline must be positive") - if request.shard_count < 1: - raise ValueError("shard_count must be positive") - if request.shard_count > 1 and request.pass_deadline_seconds is not None: - # polylogue-pzxm/polylogue-uhgm interaction: the sharded path has no - # deadline_check seam threaded to its K concurrent shard replays (see - # the dispatch site's comment in _rebuild_index_from_source_owned) -- - # reject the combination up front rather than silently ignoring the - # deadline. - raise ValueError("--shard-count does not yet honor --pass-deadline-seconds; use one or the other") - if request.operation_id is not None and ( - request.raw_ids or request.only_missing or request.max_blob_mb is not None - ): - raise ValueError("--operation-id only resumes an unfiltered full-source rebuild") - if request.operation_id is not None and ( - request.pass_byte_budget_mb is not None or request.pass_deadline_seconds is not None - ): - raise ValueError("resumed rebuild budgets are durable; omit pass budget options with --operation-id") - - -def count_source_raw_sessions(root: Path) -> int: - source_db = root / "source.db" - if not source_db.exists(): - return 0 - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - row = conn.execute("SELECT COUNT(*) FROM raw_sessions").fetchone() - return int(row[0]) if row is not None else 0 - - -def _empty_source_receipt(root: Path, consumed_evidence: dict[str, object]) -> RebuildIndexReceipt: - return RebuildIndexReceipt( - archive_root=str(root), - raw_session_count=0, - selected_raw_count=0, - skipped_by_blob_limit_count=0, - status="empty-source", - materialized=False, - materialization={}, - generation={}, - readiness={}, - replay={}, - operation=_operation_evidence(root, generation=None, transaction=None, recovery_state="empty-source"), - corpus_shape=rebuild_corpus_shape(root), - consumed_evidence=consumed_evidence, - ) - - -def total_source_blob_bytes(root: Path) -> int: - """Total blob payload the rebuild has to replay, for progress and ETA. - - Rebuild cost is bytes-bound -- bounded passes end ``deferred`` on a byte - budget, not a row count -- so percent-complete and ETA are only meaningful - against total BYTES. Counting rows would have reported a rebuild as - "half done" while the remaining half held most of the payload. - """ - source_db = root / "source.db" - if not source_db.exists(): - return 0 - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - row = conn.execute("SELECT COALESCE(SUM(blob_size), 0) FROM raw_sessions").fetchone() - return int(row[0]) if row is not None else 0 - - -def rebuild_corpus_shape(root: Path, *, index_path: Path | None = None) -> dict[str, int]: - """Return aggregate corpus evidence for a rebuild receipt. - - The values intentionally describe only durable/rebuildable storage shape, - never raw ids or payload content. Persisting raw count, payload bytes, - and the candidate index footprint lets a later timing comparison distinguish - a code regression from a differently shaped corpus. - """ - resolved_index = index_path or ArchiveLocation.resolve(root).active_index_path - try: - index_bytes = resolved_index.stat().st_size - except OSError: - index_bytes = 0 - return { - "raw_count": count_source_raw_sessions(root), - "blob_bytes": total_source_blob_bytes(root), - "index_bytes": index_bytes, - } - - -def missing_index_raw_ids(root: Path) -> list[str]: - """Return source raw_ids that have not yet reached ``index.sessions``. - - polylogue-ogn1: a missing/lost ``index.db`` (fresh archive, or one just - reset via ``ops reset --index``) means every source row is missing from - the index by definition -- return the full source set instead of an - empty list, so ``--only-missing`` actually rebuilds something on a - fresh/lost index rather than silently doing nothing. - """ - source_db = root / "source.db" - if not source_db.exists(): - return [] - index_db = ArchiveLocation.resolve(root).active_index_path - if not index_db.exists(): - return all_index_rebuild_raw_ids(root) - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - conn.execute("ATTACH DATABASE ? AS idx", (str(index_db),)) - rows = conn.execute( - """ - SELECT r.raw_id FROM raw_sessions r - WHERE NOT EXISTS (SELECT 1 FROM idx.sessions s WHERE s.raw_id = r.raw_id) - ORDER BY r.acquired_at_ms, r.raw_id - """ - ).fetchall() - return [str(row[0]) for row in rows] - - -def all_index_rebuild_raw_ids(root: Path) -> list[str]: - source_db = root / "source.db" - if not source_db.exists(): - return [] - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - rows = conn.execute("SELECT raw_id FROM raw_sessions ORDER BY acquired_at_ms, raw_id").fetchall() - return [str(row[0]) for row in rows] - - -def filter_raw_ids_by_max_blob_size(root: Path, raw_ids: list[str], max_blob_mb: float | None) -> list[str]: - if max_blob_mb is None or not raw_ids: - return raw_ids - source_db = root / "source.db" - placeholders = ",".join("?" for _ in raw_ids) - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - rows = conn.execute( - f"SELECT raw_id FROM raw_sessions WHERE raw_id IN ({placeholders}) AND blob_size <= ? " - "ORDER BY acquired_at_ms, raw_id", - (*raw_ids, int(max_blob_mb * 1024 * 1024)), - ).fetchall() - return [str(row[0]) for row in rows] - - -def select_rebuild_raw_ids(request: RebuildIndexRequest, *, raw_count: int | None = None) -> tuple[int, list[str], int]: - """Select source rows deterministically before the replay starts.""" - root = request.archive_root - if raw_count is None: - raw_count = count_source_raw_sessions(root) - raw_ids = ( - list(dict.fromkeys(request.raw_ids)) - if request.raw_ids - else missing_index_raw_ids(root) - if request.only_missing - else all_index_rebuild_raw_ids(root) - ) - unfiltered_count = len(raw_ids) - selected = filter_raw_ids_by_max_blob_size(root, raw_ids, request.max_blob_mb) - return raw_count, selected, unfiltered_count - len(selected) - - -async def rebuild_index_from_source(request: RebuildIndexRequest) -> RebuildIndexReceipt: - """Replay one source snapshot into an owned generation and optionally promote it. - - Acquires :class:`~polylogue.storage.archive_identity.OwnedArchiveLocation` - over ``request.archive_root`` before any generation directory or SQLite - tier is touched (polylogue-ovme.2 AC3): an offline rebuild is exactly the - maintenance/campaign writer ``OwnedArchiveLocation`` exists for, and this - is orthogonal to ``RebuildLease`` below (that lease serializes concurrent - *rebuild* invocations specifically; this proves the caller still owns - the *location* it resolved, catching e.g. a concurrent devtools campaign - or a foreign/rotated root before this rebuild can act on stale identity). - """ - rebuild_started_at_s = time.perf_counter() - from polylogue.storage.index_generation import RebuildLease - from polylogue.storage.sqlite.connection_profile import ( - check_mapped_bytes_budget_against_cgroup_limit, - log_mapped_bytes_budget_check, - ) - - # polylogue-e98k: this is the other path (besides daemon startup) that can - # hold a ``BULK_BUILD_WRITE_CONNECTION_PROFILE`` connection (4 GiB mmap) -- - # log the budget-vs-cgroup-limit comparison before replay starts, not only - # discoverable by symptom after a throttled/stalled rebuild. - log_mapped_bytes_budget_check(logger, check_mapped_bytes_budget_against_cgroup_limit()) - validate_rebuild_index_request(request) - root = request.archive_root - # A prior owned pass may have left its token in this task's context. The - # new request must establish its own receipt-bound token at the first - # validation rather than accidentally reusing another archive/pass. - _ACTIVE_EXTERNAL_INVENTORY_TOKEN.set(None) - require_rebuild_schema_currency(root) - pre_ownership_raw_count = count_source_raw_sessions(root) - receipt_free_empty_probe = request.operation_id is None and pre_ownership_raw_count == 0 - initial_provenance_error: RebuildProvenanceError | None = None - consumed_evidence: dict[str, object] = {} - if not receipt_free_empty_probe: - try: - consumed_evidence = _validate_rebuild_provenance_receipt(root, request.schema_inference_receipt_path) - except RebuildProvenanceError as exc: - if request.operation_id is None: - raise - # A resumable operation may need to be retired because this admission - # failed, but that lifecycle mutation must wait until both ownership - # boundaries are held. Control-flow exceptions are intentionally not - # caught here and therefore never change resumability. - initial_provenance_error = exc - location = ArchiveLocation.resolve(root) - # The joined raw-frontier projection is rooted at the co-located active - # index. A split-root canary intentionally points that index elsewhere and - # validates the selected active generation through its own receipt-bound - # route below, so a missing root/index.db must not masquerade as raw debt. - if pre_ownership_raw_count and location.active_index_path.parent == root: - from polylogue.readiness.capability import raw_frontier_source_selection_block_reason - from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot - - materialization = raw_materialization_readiness_snapshot(root) - # An available active index must agree with source and cursor - # authority before it can seed a rebuild. A missing or unreadable - # derived tier is the recovery case this operation exists to handle; - # source-only candidate admission below remains mandatory. - if materialization.get("available") is True and ( - reason := raw_frontier_source_selection_block_reason(root, materialization) - ): - raise RuntimeError(f"reindex source preflight gate failed: raw frontier integrity: {reason}") - active_config = Config( - archive_root=root, - render_root=render_root(), - sources=[], - db_path=location.active_index_path, - ) - if reason := offline_maintenance_block_reason(active_config, active=True, dry_run=False): - raise RuntimeError(reason) - from polylogue.maintenance.archive_verification import archive_verification_names_for_route, verify_archive - - source_liveness = verify_archive( - root, - checks=archive_verification_names_for_route("reindex-source-preflight"), - active_index_context=("required" if location.active_index_path.exists() else "unavailable_for_candidate"), - ) - if source_liveness.blocking: - failing = "; ".join( - f"{check.name}: {check.summary}" for check in source_liveness.checks if check.status.value == "error" - ) - raise RuntimeError(f"reindex source preflight gate failed: {failing}") - - # Ownership acquisition itself only claims the lock. Revalidate after it - # so receipt expiry/source/external-corpus drift between the cheap - # preflight and ownership acquisition cannot reach the rebuild lease or a - # candidate mutation. - owned = OwnedArchiveLocation.acquire(location) - try: - assert_owns_archive_location(owned, location) - require_rebuild_schema_currency(root) - raw_count = count_source_raw_sessions(root) - if initial_provenance_error is not None: - # A resumable request that failed admission must retire its - # transaction before any empty-source shortcut can turn the same - # invalid operation into a successful receipt. - with RebuildLease(root): - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - _mark_rebuild_transaction_stale_after_provenance_failure( - root, request.operation_id, initial_provenance_error - ) - raise initial_provenance_error - if raw_count == 0: - if request.operation_id is not None: - with RebuildLease(root): - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - _retire_empty_source_resume_transaction(root, request.operation_id) - return _empty_source_receipt(root, consumed_evidence) - consumed_evidence = _validate_rebuild_provenance_receipt( - root, - request.schema_inference_receipt_path, - inventory_token=cast(dict[str, object], consumed_evidence.get("external_ground_truth_inventory_token", {})), - ) - # The lease is itself lifecycle state guarded by the provenance gate. - # Revalidate again under the lease immediately before the owned body - # can create or mutate a candidate/transaction. - with RebuildLease(root): - try: - consumed_evidence = _validate_rebuild_provenance_receipt( - root, - request.schema_inference_receipt_path, - inventory_token=cast( - dict[str, object], consumed_evidence.get("external_ground_truth_inventory_token", {}) - ), - ) - except RebuildProvenanceError as exc: - _mark_rebuild_transaction_stale_after_provenance_failure(root, request.operation_id, exc) - raise - return await _rebuild_index_from_source_owned( - request, - root=root, - owned=owned, - consumed_evidence=consumed_evidence, - raw_count=raw_count, - rebuild_started_at_s=rebuild_started_at_s, - ) - finally: - owned.release() - - -async def _rebuild_index_from_source_owned( - request: RebuildIndexRequest, - *, - root: Path, - owned: OwnedArchiveLocation, - consumed_evidence: dict[str, object], - raw_count: int, - rebuild_started_at_s: float, -) -> RebuildIndexReceipt: - """Ownership-proven body of :func:`rebuild_index_from_source`.""" - from polylogue.maintenance.archive_verification import ( - archive_verification_names_for_route, - passes_strict_acceptance, - strict_acceptance_failures, - verify_archive, - ) - - reindex_checks = archive_verification_names_for_route("reindex-index-candidate") - cross_tier_checks = archive_verification_names_for_route("reindex-cross-tier-candidate") - canary_checks = archive_verification_names_for_route("reindex-canary-candidate") - canary_profile = "reindex-canary-v2-domain-coverage" - from polylogue.maintenance.replay import rebuild_index_from_source as replay_source - from polylogue.sources.revision_backfill import ( - RebuildDeadlineExceededError, - validate_frozen_source_authority, - ) - from polylogue.storage.archive_readiness import archive_readiness_status - from polylogue.storage.index_generation import IndexGenerationStore, rebuild_source_evidence_snapshot - from polylogue.storage.repair import repair_session_insights - - provenance = RebuildProvenanceContext( - root=root, - receipt_path=request.schema_inference_receipt_path, - source_snapshot=str(consumed_evidence.get("source_snapshot", "")), - consumed_evidence=consumed_evidence, - external_inventory_token=cast( - dict[str, object], consumed_evidence.get("external_ground_truth_inventory_token", {}) - ), - active_index_context=( - "required" if ArchiveLocation.resolve(root).active_index_path.exists() else "unavailable_for_candidate" - ), - ) - # ``rebuild_index_from_source`` already acquired this root's - # ``RebuildLease`` before any operation mutation. Retain this scope only - # to preserve the body's indentation and make the outer ownership boundary - # explicit at the public entry point. - with contextlib.nullcontext(): - if raw_count == 0: - return _empty_source_receipt(root, consumed_evidence) - resumable_full_source = not request.raw_ids and not request.only_missing and request.max_blob_mb is None - transaction = None - transaction_created_here = False - page = None - pass_started_at_ms = int(time.time() * 1000) - if resumable_full_source: - generation_store = IndexGenerationStore(owned.location, repair_anchor=False) - if request.operation_id is not None: - transaction = generation_store.load_transaction(request.operation_id) - transaction = _reconcile_active_generation_transaction(generation_store, transaction) - else: - # polylogue-to76x: share this pass's parse cache with the - # validation phase. validate_frozen_source_authority re-derives - # every source decision, which parses every selected raw; the - # replay phase below then parses the SAME content again because - # it only ever sees an empty cache. Threading the caller's cache - # in makes the second phase reuse the first phase's parses - # instead of repeating them. When no caller supplied one (every - # CLI/HTTP path) this stays None and behaviour is unchanged -- - # the replay phase still warms its own cache further down. - validate_frozen_source_authority(root, prefetch_cache=request.prefetch_cache) - transaction = _create_rebuild_transaction_after_receipt_validation( - generation_store, request, provenance - ) - transaction_created_here = True - if transaction.status in {"promoted", "promoted-attestation-failed", "stale"}: - raise RuntimeError( - f"rebuild operation {transaction.operation_id} is {transaction.status}; start a new operation" - ) - if rebuild_source_evidence_snapshot(root) != transaction.source_snapshot: - if transaction_created_here: - mismatch = RebuildProvenanceError( - "rebuild schema-inference preflight gate failed: " - "source evidence changed since this rebuild was planned" - ) - _cleanup_transaction_after_provenance_failure( - generation_store, - transaction, - root, - request.schema_inference_receipt_path, - consumed_evidence, - mismatch, - ) - raise mismatch - try: - _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="stale", - error="source evidence changed since this rebuild was planned", - ) - except RebuildProvenanceError as exc: - if transaction_created_here: - _cleanup_transaction_after_provenance_failure( - generation_store, - transaction, - root, - request.schema_inference_receipt_path, - consumed_evidence, - exc, - ) - raise - raise RuntimeError( - f"rebuild operation {transaction.operation_id} is stale because source evidence changed" - ) - generation = generation_store.load(transaction.generation_id) - if generation.owner_id != transaction.generation_owner_id or generation.state != "inactive": - raise RuntimeError(f"rebuild operation {transaction.operation_id} lost its inactive candidate") - if not transaction.derived_stores_cleared: - try: - provenance.validate() - _clear_bulk_build_derived_stores(Path(generation.index_path)) - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status=transaction.status, - derived_stores_cleared=True, - ) - except RebuildProvenanceError as exc: - if transaction_created_here: - _cleanup_transaction_after_provenance_failure( - generation_store, - transaction, - root, - request.schema_inference_receipt_path, - consumed_evidence, - exc, - ) - raise - # polylogue-6mvg: the pass's SELECTION phase -- which raws - # replay THIS pass -- previously had no durable timing at all. A - # live full rebuild spent ~86s of one CPU core on selection - # alone before the inactive generation held a single session, - # visible only as an unexplained gap between the pass starting - # and its first "backfill stage timings" log line. - selection_started_at = time.perf_counter() - page = generation_store.next_raw_page(transaction, limit=request.raw_batch_size) - selection_elapsed_s = time.perf_counter() - selection_started_at - selected_raw_ids = [raw_id for raw_id, _blob_hash_hex, _blob_size in page.rows] - selected_raw_count = len(selected_raw_ids) - skipped_by_blob_limit_count = 0 - if not transaction_created_here and selected_raw_ids: - # polylogue-to76x: the resumed-pass twin of the first-pass - # validation below. Same reason it needs the cache: this - # re-derives every selected source decision (parsing this - # page's raws) immediately before the replay phase parses the - # same page again. - validate_frozen_source_authority( - root, - selected_raw_ids=selected_raw_ids, - prefetch_cache=request.prefetch_cache, - ) - else: - selection_started_at = time.perf_counter() - raw_count, selected_raw_ids, skipped_by_blob_limit_count = select_rebuild_raw_ids( - request, raw_count=raw_count - ) - selection_elapsed_s = time.perf_counter() - selection_started_at - selected_raw_count = len(selected_raw_ids) - validate_frozen_source_authority( - root, - selected_raw_ids=selected_raw_ids, - ) - generation_store = IndexGenerationStore(owned.location, repair_anchor=request.promote) - provenance.validate() - generation = generation_store.create(source_snapshot=rebuild_source_evidence_snapshot(root)) - try: - selection_evidence = rebuild_selection_evidence( - selected_raw_ids, - archive_root=root, - generation_id=generation.generation_id, - generation_owner_id=generation.owner_id, - candidate_index=Path(generation.index_path), - source_snapshot=generation.source_snapshot, - selected_session_ids=request.selected_session_ids, - ) - except BaseException as exc: - if transaction is None: - _cleanup_nonresumable_generation_failure( - generation_store, - generation, - root=root, - receipt_path=request.schema_inference_receipt_path, - consumed_evidence=consumed_evidence, - primary=exc, - ) - raise - sharded_replay = request.shard_count > 1 and len(selected_raw_ids) >= request.shard_count - source_drifted = False - try: - provenance.validate() - generation_root = Path(generation.index_path).parent - config = Config( - archive_root=generation_root, - render_root=render_root(), - sources=[], - db_path=Path(generation.index_path), - ) - # polylogue-czq2: warm THIS pass's own raw ids before replay when - # the caller did not already hand us a prefetch cache (the - # daemon's bulk-rebuild loop is the only caller that does -- - # `resolve_or_start_daemon_bulk_rebuild_transaction` warms off a - # writer hold it does not yet hold, so its cache is left alone - # here). Every other caller (offline CLI, the daemon's own HTTP - # rebuild-index route) gets the same census-parse seam the daemon - # loop always had, closing the gap that let ``spill_load`` pay a - # full serial re-parse/reload cost on those routes. Reads from the - # REAL archive root (`root`), not `generation_root`: source.db and - # the blob store live beside the outer archive, never inside a - # not-yet-promoted generation directory. - # A pre-warmed caller cache belongs to the caller's prior phase; - # only charge this pass for the bounded offline warm it performs - # itself. Keep the distinct replay clock below so ``replay_s`` - # remains comparable with historical receipts. - pass_started_at_s = time.perf_counter() - prefetch_warm_s = 0.0 - effective_prefetch_cache = request.prefetch_cache - if effective_prefetch_cache is None and selected_raw_ids: - prefetch_started_at_s = time.perf_counter() - warm_config = Config(archive_root=root, render_root=render_root(), sources=[]) - effective_prefetch_cache = await asyncio.to_thread( - _warm_offline_prefetch_cache, warm_config, selected_raw_ids - ) - prefetch_warm_s = time.perf_counter() - prefetch_started_at_s - replay_started_at_s = time.perf_counter() - if sharded_replay: - # polylogue-pzxm: build request.shard_count owned-inactive - # generations in parallel and merge them into `generation` - # instead of replaying `selected_raw_ids` through the single - # writer directly. Every terminal stage below (planner - # statistics, bulk-build repopulate, FTS parity, readiness, - # promote) is unchanged: it observes `generation.index_path` - # after the merge exactly as it would after a sequential - # replay of the same raw ids. - # - # polylogue-uhgm interaction: the sharded path does NOT yet - # honor mid-replay pass deadlines (`_check_pass_deadline` - # below is wired only through the non-sharded - # `replay_source` call's `deadline_check` param) -- a shard's - # own `backfill_historical_revision_evidence` call has no - # deadline threaded to it, and there is no natural per-cohort - # checkpoint to interrupt across K concurrently-running - # shards the way one single-writer replay has. This is - # deliberately rejected up front instead of silently - # ignored: `validate_rebuild_index_request` refuses - # `shard_count > 1` together with `pass_deadline_seconds`, - # and this defends the resumed-operation case (a deadline - # set on transaction CREATION, before this pass's own - # `request.shard_count` was chosen) the request-level - # validator cannot see. - if transaction is not None and transaction.pass_deadline_ms is not None: - raise ValueError( - f"rebuild operation {transaction.operation_id} carries a pass deadline " - f"({transaction.pass_deadline_ms}ms); shard_count > 1 does not yet honor mid-replay " - "deadlines -- resume with shard_count=1, or start a new undeadlined operation" - ) - from polylogue.maintenance.sharded_rebuild import replay_selected_raw_ids_sharded - - replay = await replay_selected_raw_ids_sharded( - root=root, - generation_store=generation_store, - generation=generation, - selected_raw_ids=selected_raw_ids, - raw_batch_size=request.raw_batch_size, - shard_count=request.shard_count, - prefetch_cache=effective_prefetch_cache, - provenance=provenance, - ) - else: - # polylogue-uhgm: the pass deadline used to be checked only AFTER - # this whole page's replay_source() call returned, so a page that - # expanded into a much larger authority cohort (or simply ran - # slow) could overshoot ``pass_deadline_ms`` by an entire page -- - # live evidence: a 300s deadline, ~8-9 minute pages. This closure - # is threaded down to backfill_historical_revision_evidence's - # REPLAY-phase cohort loops (see RebuildDeadlineExceededError's - # docstring), which call it between cohorts. It is a no-op for - # every non-resumable selection (``transaction is None`` -- - # raw_ids/--only-missing/--max-blob-mb runs never carry a - # pass_deadline_ms in the first place, see - # validate_rebuild_index_request). - def _check_pass_deadline() -> None: - if transaction is None or transaction.pass_deadline_ms is None: - return - elapsed_ms = int(time.time() * 1000) - pass_started_at_ms - if elapsed_ms >= transaction.pass_deadline_ms: - raise RebuildDeadlineExceededError( - f"rebuild pass deadline ({transaction.pass_deadline_ms}ms) exceeded mid-replay " - f"after {elapsed_ms}ms; stopping before the next cohort" - ) - - try: - replay = await replay_source( - config, - raw_ids=selected_raw_ids, - raw_batch_size=request.raw_batch_size, - ingest_workers=None, - materialize=True, - progress_callback=None, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - # polylogue-crd8: this is the offline rebuild path (an owned - # inactive generation, never the live daemon ingest path), so - # the guard-gated bulk FTS mode is safe to enable unconditionally - # here -- it collapses whale prefix-sharing lineage cascades' - # per-row messages_fts trigger storm into one bulk delete+insert - # per affected session. - bulk_fts=True, - # polylogue-v6i3: the broader bulk-generation-build lifecycle -- - # every per-session messages_fts/blocks_command_trigram/ - # action_pairs/delegation_facts refresh is skipped during this - # replay (safe for a full OR partial/diagnostic selection: a - # repopulate from `blocks` always matches whatever sessions - # actually got replayed into this generation); see - # _repopulate_bulk_build_derived_state, called below right - # before readiness. - bulk_build=True, - prefetch_cache=effective_prefetch_cache, - deadline_check=_check_pass_deadline if transaction is not None else None, - ) - except RebuildDeadlineExceededError as exc: - # Mid-page interrupt: at least one cohort was durably - # committed (or none, if the very first cohort tripped it), - # but never the whole requested page. Do NOT advance the - # transaction's cursor/processed counters -- leaving them at - # their pre-pass values means the next pass re-derives from - # exactly the same source-order position. Re-applying any - # cohort this pass DID commit is a safe idempotent no-op - # (content-hash upsert), so this can never duplicate or skip - # a raw/cohort; it can only redo bounded work. - assert transaction is not None # deadline_check is only wired when transaction is not None - pass_elapsed_s = time.perf_counter() - replay_started_at_s - if rebuild_source_evidence_snapshot(root) != transaction.source_snapshot: - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="stale", - error="source evidence changed during deadline-interrupted rebuild pass", - ) - source_drifted = True - raise RuntimeError( - f"rebuild operation {transaction.operation_id} is stale because source evidence changed" - ) from exc - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="deferred", - error=str(exc), - ) - from polylogue.pipeline.services.process_pool import ( - parallel_threads_effective, - resolve_parse_worker_count, - ) - - pass_cost = RebuildPassCost( - selection_s=selection_elapsed_s, - cohort_s=0.0, - prefetch_warm_s=prefetch_warm_s, - replay_s=pass_elapsed_s, - checkpoint_s=0.0, - pass_s=time.perf_counter() - pass_started_at_s, - raws=selected_raw_count, - bytes_in=sum(row[2] for row in page.rows) if page is not None else 0, - processed_raws=transaction.processed_raw_count, - processed_bytes=transaction.processed_blob_bytes, - total_raws=raw_count, - total_bytes=total_source_blob_bytes(root), - free_threaded=parallel_threads_effective(), - parse_workers=resolve_parse_worker_count(), - ) - logger.info( - "rebuild_pass_cost", - generation_id=generation.generation_id, - deferred_reason="pass-deadline-mid-replay", - **pass_cost.to_dict(), - ) - pass_receipt = RebuildIndexReceipt( - archive_root=str(root), - raw_session_count=raw_count, - selected_raw_count=selected_raw_count, - skipped_by_blob_limit_count=0, - status="deferred", - materialized=False, - materialization={}, - generation=cast(dict[str, object], asdict(generation)), - readiness={}, - # Zero-shaped like a normal replay dict (not merely a bare - # marker) so every existing consumer that indexes - # replay-dict keys unconditionally (the daemon-mode CLI - # text formatter, the daemon HTTP route's raw - # ``receipt.to_dict()`` response) keeps working: this pass - # made no *measured* progress by design (see the "do not - # advance the cursor" comment above), so reporting zeros - # here is accurate, not merely a placeholder shape. - replay={ - "scanned_raw_count": 0, - "classified_full_count": 0, - "replayed_logical_source_count": 0, - "quarantined_raw_count": 0, - "adoption_deferred_raw_count": 0, - "authority_selection_expanded": True, - "scheduled_raw_count": len(selected_raw_ids), - "raw_batch_size": request.raw_batch_size, - "ingest_workers": None, - "parse_s": 0.0, - "apply_s": 0.0, - "stage_timings_s": {}, - "deferred_reason": "pass-deadline-mid-replay", - }, - transaction=cast(dict[str, object], asdict(transaction)), - operation=_operation_evidence( - root, generation=generation, transaction=transaction, recovery_state="deferred" - ), - selection_evidence=selection_evidence, - timings_s=cast(dict[str, float], pass_cost.to_dict()), - corpus_shape=rebuild_corpus_shape(root, index_path=Path(generation.index_path)), - consumed_evidence=provenance.receipt_evidence(), - ) - pass_receipt = _save_rebuild_pass_receipt_after_receipt_validation( - generation_store, - transaction.operation_id, - pass_receipt, - provenance, - ) - return pass_receipt - pass_elapsed_s = time.perf_counter() - replay_started_at_s - processed_before = transaction.processed_raw_count if transaction is not None else None - _validate_before_derived_state(provenance) - if selected_raw_ids and _should_refresh_generation_planner_statistics( - processed_before=processed_before, - processed_after=(processed_before or 0) + len(selected_raw_ids), - ): - _refresh_generation_planner_statistics(Path(generation.index_path)) - if transaction is not None and selected_raw_ids: - if rebuild_source_evidence_snapshot(root) != transaction.source_snapshot: - if transaction_created_here: - raise RebuildProvenanceError( - "rebuild schema-inference preflight gate failed: " - "source evidence changed during this bounded rebuild pass" - ) - provenance.validate() - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="stale", - error="source evidence changed during this bounded rebuild pass", - ) - source_drifted = True - raise RuntimeError( - f"rebuild operation {transaction.operation_id} is stale because source evidence changed" - ) - # The generation-local membership is the resume authority. It - # is advanced only after replay has durably committed its - # corresponding candidate output. A crash before this update - # safely replays the same bounded page; a crash after it - # cannot make a later source arrival eligible. - generation_store.commit_candidate_membership(generation, selected_raw_ids) - assert page is not None - last_raw_id, last_blob_hash_hex, _blob_size = page.rows[-1] - elapsed_ms = int(time.time() * 1000) - pass_started_at_ms - deadline_expired = ( - transaction.pass_deadline_ms is not None and elapsed_ms >= transaction.pass_deadline_ms - ) - status = "deferred" if page.deferred_reason == "byte-budget" or deadline_expired else "paused" - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status=status, - last_blob_hash_hex=last_blob_hash_hex, - last_raw_id=last_raw_id, - processed_raw_count=transaction.processed_raw_count + len(selected_raw_ids), - processed_blob_bytes=transaction.processed_blob_bytes + sum(row[2] for row in page.rows), - ) - if page.has_more or deadline_expired: - from polylogue.pipeline.services.process_pool import ( - parallel_threads_effective, - resolve_parse_worker_count, - ) - - pass_cost = RebuildPassCost( - selection_s=selection_elapsed_s, - cohort_s=_cohort_seconds(replay.get("stage_timings_s", {})), - prefetch_warm_s=prefetch_warm_s, - replay_s=pass_elapsed_s, - checkpoint_s=0.0, - pass_s=time.perf_counter() - pass_started_at_s, - raws=selected_raw_count, - bytes_in=sum(row[2] for row in page.rows), - processed_raws=transaction.processed_raw_count, - processed_bytes=transaction.processed_blob_bytes, - total_raws=raw_count, - total_bytes=total_source_blob_bytes(root), - free_threaded=parallel_threads_effective(), - parse_workers=resolve_parse_worker_count(), - parse_s=cast(float, replay.get("parse_s", 0.0)), - apply_s=cast(float, replay.get("apply_s", 0.0)), - ) - logger.info( - "rebuild_pass_cost", - generation_id=generation.generation_id, - **pass_cost.to_dict(), - ) - pass_receipt = RebuildIndexReceipt( - archive_root=str(root), - raw_session_count=raw_count, - selected_raw_count=selected_raw_count, - skipped_by_blob_limit_count=0, - status=status, - materialized=False, - materialization={}, - generation=cast(dict[str, object], asdict(generation)), - readiness={}, - replay=replay, - transaction=cast(dict[str, object], asdict(transaction)), - operation=_operation_evidence( - root, generation=generation, transaction=transaction, recovery_state=status - ), - selection_evidence=selection_evidence, - timings_s=cast(dict[str, float], pass_cost.to_dict()), - corpus_shape=rebuild_corpus_shape(root, index_path=Path(generation.index_path)), - consumed_evidence=provenance.receipt_evidence(), - ) - pass_receipt = _save_rebuild_pass_receipt_after_receipt_validation( - generation_store, - transaction.operation_id, - pass_receipt, - provenance, - ) - return pass_receipt - # polylogue-o56w: terminal-stage costs used to survive only as log - # lines; collect them here and persist them on the final receipt - # so a full rebuild's cost breakdown is durable forensics. - # - # polylogue-6mvg: ``selection_s`` (this pass's raw-id/page - # selection cost, measured above before replay even started) is - # folded in here too -- extending the SAME receipt vocabulary - # the deferred/paused ``RebuildPassCost`` timings already use, - # not a parallel one, so every receipt shape (deferred, paused, - # replayed) carries the identical key for this phase. - terminal_timings_s: dict[str, float] = {"selection_s": selection_elapsed_s} - # Census can create membership rows and logical-source keys while - # replay is running. Recompute the receipt commitment from the - # post-replay source tier so it names the closure the candidate - # actually consumed, not only the caller's raw-id hints. - selection_evidence = rebuild_selection_evidence( - selected_raw_ids, - archive_root=root, - generation_id=generation.generation_id, - generation_owner_id=generation.owner_id, - candidate_index=Path(generation.index_path), - source_snapshot=generation.source_snapshot, - selected_session_ids=request.selected_session_ids, - ) - if rebuild_source_evidence_snapshot(root) != generation.source_snapshot: - if transaction is not None and transaction_created_here: - raise RebuildProvenanceError( - "rebuild schema-inference preflight gate failed: " - "source evidence changed before terminal readiness" - ) - if transaction is not None: - provenance.validate() - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="stale", - error="source evidence changed before terminal readiness", - ) - source_drifted = True - raise RuntimeError(f"source evidence changed while rebuilding {generation.generation_id}") - source_evidence_after = rebuild_source_evidence_snapshot(root) - # polylogue-v6i3: bulk-build replay (bulk_build=True above) left - # messages_fts/blocks_command_trigram/action_pairs/delegation_facts - # empty or stale for every session -- repopulate all four - # archive-wide exactly once here, then prove exact parity before - # readiness can observe (and silently accept) a mismatch. - _validate_before_derived_state(provenance) - bulk_timings_s = _repopulate_bulk_build_derived_state(Path(generation.index_path)) - for stage, elapsed_s in bulk_timings_s.items(): - terminal_timings_s[f"terminal.bulk_build.{stage}"] = elapsed_s - logger.info( - "rebuild_terminal_stage_complete", - generation_id=generation.generation_id, - stage=f"bulk_build.{stage}", - elapsed_s=round(elapsed_s, 3), - ) - # Blob bytes are checked after replay and bulk derivation, before - # candidate acceptance/readiness can observe a corrupted source. - # The receipt's source.db digest is only a binding; BlobStore is - # the byte-level authority for the current referenced universe. - _validate_before_derived_state(provenance, verify_blob_integrity=True) - terminal_started_at = time.perf_counter() - # polylogue-t0m73: the index-only reindex acceptance gate -- every - # ground-truth check whose universe is satisfiable from a - # generation directory's index.db alone (source.db/user.db/ - # embeddings.db live once at the archive root, not per - # generation, so cross-tier checks are excluded; see - # candidate route declaration). This subsumed the older - # fts-parity-only gate. - candidate_acceptance_checks = ( - canary_checks - if request.canary - else ( - request.candidate_acceptance_checks - if request.candidate_acceptance_checks is not None - else cross_tier_checks - ) - ) - # polylogue-f1vg: an inactive generation has only index.db, so - # corpus fidelity combines that candidate with the durable - # source tier at the archive root before promotion. - candidate_report = verify_archive( - root, - checks=candidate_acceptance_checks, - index_path_override=Path(generation.index_path), - ) - # ChatGPT conservation is deliberately non-vacuous when ChatGPT - # source rows exist, but a provider-neutral archive may have no - # ChatGPT population at all. Do not turn that legitimate - # non-applicability into a failed candidate admission. - candidate_required_checks = tuple( - name - for name in candidate_acceptance_checks - if not any( - check.name == name - and check.status.value == "skip" - and getattr(check, "evidence", {}).get("outcome_reason") == "no_chatgpt_population" - for check in candidate_report.checks - ) - ) - acceptance_reports = ( - verify_archive(generation_root, checks=reindex_checks), - candidate_report, - ) - terminal_timings_s["terminal.reindex_acceptance"] = time.perf_counter() - terminal_started_at - logger.info( - "rebuild_terminal_stage_complete", - generation_id=generation.generation_id, - stage="reindex_acceptance", - elapsed_s=round(terminal_timings_s["terminal.reindex_acceptance"], 3), - ) - acceptance_requirements = ( - reindex_checks, - candidate_required_checks, - ) - acceptance_failures = tuple( - failure - for report, required_checks in zip(acceptance_reports, acceptance_requirements, strict=True) - if not passes_strict_acceptance( - report, - required_checks=required_checks, - allow_not_applicable=report is acceptance_reports[1], - ) - for failure in strict_acceptance_failures( - report, - required_checks=required_checks, - allow_not_applicable=report is acceptance_reports[1], - ) - ) - if acceptance_failures: - failing = "; ".join(acceptance_failures) - raise RuntimeError( - f"reindex acceptance gate failed for generation {generation.generation_id}: {failing}" - ) - canary_acceptance: dict[str, object] | None = None - if request.canary: - canary_results: list[dict[str, object]] = [ - { - "name": check.name, - "status": check.status.value, - "summary": check.summary, - "count": check.count, - } - for check in acceptance_reports[1].checks - if check.name in canary_checks - ] - canary_acceptance = { - "profile": canary_profile, - "results": canary_results, - } - # Derived insight materialization assumes a coherent lineage graph. - # Reject a structurally invalid inactive candidate before invoking - # it, so the acceptance receipt names the actual bad invariant - # instead of reporting an incidental derived-model failure. - terminal_started_at = time.perf_counter() - _validate_before_derived_state(provenance) - insight_result = repair_session_insights( - config, - dry_run=False, - archive_root_override=generation_root, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - resolve_convergence_debt=False, - ) - terminal_timings_s["terminal.session_insights"] = time.perf_counter() - terminal_started_at - logger.info( - "rebuild_terminal_stage_complete", - generation_id=generation.generation_id, - stage="session_insights", - elapsed_s=round(terminal_timings_s["terminal.session_insights"], 3), - ) - if not insight_result.success: - raise RuntimeError(f"session insight materialization failed: {insight_result.detail}") - terminal_started_at = time.perf_counter() - readiness = archive_readiness_status(generation_root) - terminal_timings_s["terminal.readiness"] = time.perf_counter() - terminal_started_at - logger.info( - "rebuild_terminal_stage_complete", - generation_id=generation.generation_id, - stage="readiness", - elapsed_s=round(terminal_timings_s["terminal.readiness"], 3), - ) - if not readiness.get("checked") or int(readiness.get("blocked_surface_count", 1)) != 0: - blocked = [ - name - for name, info in cast(dict[str, dict[str, object]], readiness.get("surfaces", {})).items() - if info.get("ready") is not True - ] - detail = ( - f"reason: {readiness.get('reason')}" - if not readiness.get("checked") - else "blocked surfaces: " + ", ".join(blocked) - ) - raise RuntimeError(f"inactive generation {generation.generation_id} is not exact-ready; {detail}") - # Candidate readiness is the last fallible validation boundary. - # Verify the bytes named by source.db through BlobStore here, - # before a pointer can be flipped, rather than treating a source - # tier hash or an earlier receipt as proof of current blob bytes. - _validate_before_derived_state( - provenance, - verify_blob_integrity=True, - refresh_blob_integrity=True, - ) - if transaction is not None: - transaction = _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="ready", - ) - if request.promote: - # Re-prove ownership immediately before the activation swap: - # a long-running rebuild pass can outlast a concurrent - # promotion of a different generation, and this must be - # caught before clobbering someone else's activation rather - # than after (polylogue-ovme.2 AC3). - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - provenance.validate() - terminal_started_at = time.perf_counter() - generation = generation_store.promote(generation) - terminal_timings_s["terminal.promote"] = time.perf_counter() - terminal_started_at - logger.info( - "rebuild_terminal_stage_complete", - generation_id=generation.generation_id, - stage="promote", - elapsed_s=round(terminal_timings_s["terminal.promote"], 3), - ) - if transaction is not None: - # The pointer is active now. Persist only a non-failing - # lifecycle transition after this point. In particular, - # do not call the receipt validator after promotion: a - # changed external root or blob must never turn an active - # generation back into a resumable ``ready`` candidate. - attestation: dict[str, object] - try: - attestation = { - "status": "passed", - "generation_id": generation.generation_id, - "generation_state": generation.state, - "active_pointer": str(generation_store.active_pointer), - } - transaction = generation_store.checkpoint_transaction( - transaction, - status="promoted", - post_promotion_attestation=attestation, - consumed_evidence=provenance.receipt_evidence(), - ) - except Exception as attestation_error: - source_drifted = True - try: - transaction = generation_store.load_transaction(transaction.operation_id) - transaction = generation_store.checkpoint_transaction( - transaction, - status="promoted-attestation-failed", - error=str(attestation_error), - post_promotion_attestation={ - "status": "failed", - "generation_id": generation.generation_id, - "generation_state": "active", - "error": str(attestation_error), - }, - ) - except Exception as recovery_error: - attestation_error.add_note( - "active generation post-promotion attestation checkpoint failed: " - f"{type(recovery_error).__name__}: {recovery_error}" - ) - raise - raise - except Exception as exc: - fresh_provenance_failure = isinstance(exc, RebuildProvenanceError) and ( - transaction_created_here or transaction is None or sharded_replay - ) - if fresh_provenance_failure: - if transaction is not None: - cleanup_errors = _discard_transaction_after_provenance_failure( - generation_store, transaction, provenance - ) - else: - cleanup_errors = _discard_generation_after_provenance_failure( - generation_store, generation, provenance - ) - _add_cleanup_failure_notes(exc, cleanup_errors, label="rebuild provenance") - elif transaction is not None and not source_drifted: - try: - # A process can fail after ``checkpoint_transaction`` has - # atomically published the output batch but before this - # frame receives its returned replacement object. Never - # let this stale local value rewind that committed cursor - # while recording recovery state. - transaction = generation_store.load_transaction(transaction.operation_id) - _checkpoint_rebuild_transaction_after_receipt_validation( - generation_store, - transaction, - provenance, - status="failed", - error="bounded rebuild pass failed; candidate retained for diagnosis or explicit recovery", - ) - except BaseException as checkpoint_error: - exc.add_note( - "resumable rebuild failure-state checkpoint also failed: " - f"{type(checkpoint_error).__name__}: {checkpoint_error}" - ) - elif transaction is None: - _cleanup_nonresumable_generation_failure( - generation_store, - generation, - root=root, - receipt_path=request.schema_inference_receipt_path, - consumed_evidence=consumed_evidence, - primary=exc, - ) - raise - try: - final_receipt = RebuildIndexReceipt( - archive_root=str(root), - raw_session_count=raw_count, - selected_raw_count=selected_raw_count, - skipped_by_blob_limit_count=skipped_by_blob_limit_count, - status="replayed", - materialized=True, - materialization=cast(dict[str, object], insight_result.to_dict()), - generation=cast(dict[str, object], asdict(generation)), - readiness=cast(dict[str, object], readiness), - replay=replay, - transaction=cast(dict[str, object], asdict(transaction)) if transaction is not None else None, - operation=_operation_evidence( - root, - generation=generation, - transaction=transaction, - recovery_state="promoted" if request.promote else "ready", - ), - selection_evidence=selection_evidence, - source_evidence_after=source_evidence_after, - canary_acceptance=canary_acceptance, - timings_s=_receipt_timings( - rebuild_s=time.perf_counter() - rebuild_started_at_s, - selection_s=selection_elapsed_s, - prefetch_warm_s=prefetch_warm_s, - replay=replay, - terminal_timings_s=terminal_timings_s, - ), - corpus_shape=rebuild_corpus_shape(root, index_path=Path(generation.index_path)), - consumed_evidence=provenance.receipt_evidence(), - ) - try: - _persist_candidate_receipt(generation, final_receipt.to_dict()) - except BaseException as attestation_error: - if transaction is not None and transaction.status in {"promoted", "promoted-attestation-failed"}: - source_drifted = True - try: - transaction = generation_store.load_transaction(transaction.operation_id) - transaction = generation_store.checkpoint_transaction( - transaction, - status="promoted-attestation-failed", - error=str(attestation_error), - post_promotion_attestation={ - "status": "failed", - "generation_id": generation.generation_id, - "generation_state": "active", - "error": str(attestation_error), - }, - consumed_evidence=provenance.receipt_evidence(), - ) - except BaseException as recovery_error: - attestation_error.add_note( - "active generation candidate-receipt attestation checkpoint failed: " - f"{type(recovery_error).__name__}: {recovery_error}" - ) - raise - if transaction is not None: - if transaction.status in {"promoted", "promoted-attestation-failed"}: - # Promotion already crossed the pointer boundary. Receipt - # persistence here is an attestation record, not another - # admission gate that can demote or leave the active - # generation resumable. - try: - generation_store.save_pass_receipt(transaction.operation_id, final_receipt.to_dict()) - except BaseException as attestation_error: - source_drifted = True - try: - transaction = generation_store.load_transaction(transaction.operation_id) - transaction = generation_store.checkpoint_transaction( - transaction, - status="promoted-attestation-failed", - error=str(attestation_error), - post_promotion_attestation={ - "status": "failed", - "generation_id": generation.generation_id, - "generation_state": "active", - "error": str(attestation_error), - }, - consumed_evidence=provenance.receipt_evidence(), - ) - except BaseException as recovery_error: - attestation_error.add_note( - "active generation receipt-attestation checkpoint failed: " - f"{type(recovery_error).__name__}: {recovery_error}" - ) - raise - else: - final_receipt = _save_rebuild_pass_receipt_after_receipt_validation( - generation_store, - transaction.operation_id, - final_receipt, - provenance, - ) - except BaseException as exc: - if transaction is None: - _cleanup_nonresumable_generation_failure( - generation_store, - generation, - root=root, - receipt_path=request.schema_inference_receipt_path, - consumed_evidence=consumed_evidence, - primary=exc, - ) - raise - return final_receipt - - -def rebuild_index_from_source_sync(request: RebuildIndexRequest) -> RebuildIndexReceipt: - """Synchronous adapter for offline CLI callers.""" - return asyncio.run(rebuild_index_from_source(request)) - - -def rebuild_status( - archive_root: Path, - *, - operation_id: str | None = None, - include_daemon_bulk_rebuild: bool = True, -) -> dict[str, object]: - """Consolidated raw-replay rebuild status for operator/agent surfaces. - - polylogue-b5l.1 AC5: one read gives lease ownership, the active - generation, the resumable transaction's cursor/delta, and explicit - stale-lock/failed-transaction recovery guidance -- instead of an operator - hand-cross-referencing ``.index-rebuild.lock``, ``.index-active-pointer``, - and a transaction JSON file under ``.index-rebuild-transactions/``. - - ``operation_id`` selects which persisted transaction to report. When - omitted and ``include_daemon_bulk_rebuild`` is True (the default), this - falls back to the daemon's own well-known bulk-rebuild operation id - (``DAEMON_BULK_REBUILD_OPERATION_ID``) -- the common case for - ``ops reset --index && polylogued run``, where the daemon never has an - explicit operation id to hand the caller. Read-only throughout: never - acquires ``RebuildLease``, never mutates any transaction or generation. - """ - from polylogue.daemon.bulk_rebuild import DAEMON_BULK_REBUILD_OPERATION_ID - from polylogue.storage.index_generation import ( - IndexGenerationStore, - rebuild_lease_status, - rebuild_source_evidence_snapshot, - ) - - location = ArchiveLocation.resolve(archive_root) - lease = rebuild_lease_status(archive_root) - store = IndexGenerationStore(location) - - active_generation: dict[str, object] | None = None - try: - active_target = store.active_pointer.resolve(strict=True) - except OSError: - active_target = None - if active_target is not None: - for metadata_path in store.generations_root.glob("gen-*/generation.json"): - try: - generation = store.load(metadata_path.parent.name) - if generation.state == "active" and Path(generation.index_path).resolve(strict=True) == active_target: - active_generation = cast(dict[str, object], asdict(generation)) - break - except (OSError, ValueError, TypeError): - continue - - schema_version: int | None = None - try: - with contextlib.closing(sqlite3.connect(f"file:{store.active_pointer}?mode=ro", uri=True, timeout=5.0)) as conn: - row = conn.execute("PRAGMA user_version").fetchone() - schema_version = int(row[0]) if row is not None else None - except sqlite3.Error: - schema_version = None - - resolved_operation_id = operation_id - if resolved_operation_id is None and include_daemon_bulk_rebuild: - resolved_operation_id = DAEMON_BULK_REBUILD_OPERATION_ID - - transaction_payload: dict[str, object] | None = None - delta: dict[str, object] | None = None - membership: dict[str, int] | None = None - transaction = None - if resolved_operation_id is not None: - try: - transaction = store.load_transaction(resolved_operation_id) - except FileNotFoundError: - transaction = None - except (OSError, ValueError, TypeError, KeyError): - transaction = None - if transaction is not None: - transaction_payload = cast(dict[str, object], asdict(transaction)) - try: - membership = store.candidate_membership_status(store.load(transaction.generation_id)) - except (OSError, RuntimeError, sqlite3.Error): - membership = None - current_snapshot = ( - rebuild_source_evidence_snapshot(archive_root) if (archive_root / "source.db").exists() else None - ) - delta = { - "source_snapshot_matches": ( - current_snapshot is not None and current_snapshot == transaction.source_snapshot - ), - "current_source_snapshot": current_snapshot, - "transaction_source_snapshot": transaction.source_snapshot, - } - - recovery: list[str] = [] - if lease.stale: - recovery.append( - f"lease lock file records dead pid={lease.holder_pid} host={lease.holder_host!r}; " - "the kernel lock is still authoritative, so a surviving process or inherited file descriptor " - "still owns the lease; locate the holder of .index-rebuild.lock (for example with lslocks or " - "lsof), stop that holder cleanly, and retry" - ) - if transaction_payload is not None and transaction_payload.get("status") == "failed": - recovery.append( - f"transaction {resolved_operation_id!r} is failed: {transaction_payload.get('error')!r}; " - "resume with the same --operation-id to retry the same candidate, or discard it to start fresh" - ) - if delta is not None and delta.get("source_snapshot_matches") is False: - recovery.append( - f"transaction {resolved_operation_id!r} source snapshot no longer matches current source.db; " - "the next pass against this operation id will refuse as stale -- start a new operation" - ) - - return { - "archive_root": str(archive_root), - "lease": lease.to_dict(), - "generation": active_generation, - "schema_version": schema_version, - "operation_id": resolved_operation_id, - "transaction": transaction_payload, - "membership": membership, - "operation": _operation_evidence( - archive_root, - generation=None, - transaction=transaction, - recovery_state=(str(transaction_payload["status"]) if transaction_payload is not None else "idle"), - ), - "delta": delta, - "recovery": recovery, - } - - -__all__ = [ - "RebuildIndexReceipt", - "RebuildIndexRequest", - "all_index_rebuild_raw_ids", - "count_source_raw_sessions", - "filter_raw_ids_by_max_blob_size", - "missing_index_raw_ids", - "rebuild_index_from_source", - "rebuild_index_from_source_sync", - "rebuild_status", - "select_rebuild_raw_ids", - "validate_rebuild_index_request", -] diff --git a/polylogue/maintenance/reindex_canary.py b/polylogue/maintenance/reindex_canary.py deleted file mode 100644 index 251711b396..0000000000 --- a/polylogue/maintenance/reindex_canary.py +++ /dev/null @@ -1,3011 +0,0 @@ -"""Read-only semantic comparison for an inactive reindex canary. - -The rebuild service owns creation of an inactive generation. This module owns -the other half of the canary contract: compare the real SQLite read models in -that generation with the active index and account for every row difference. -It deliberately opens both inputs read-only and never knows how to promote, -repair, or otherwise mutate a generation. -""" - -from __future__ import annotations - -import json -import os -import re -import sqlite3 -import tempfile -from collections import Counter -from collections.abc import Iterable -from dataclasses import dataclass, replace -from enum import StrEnum -from hashlib import sha256 -from pathlib import Path -from typing import Any, cast - - -class DifferenceOperation(StrEnum): - """The row-level change observed between the active and candidate indexes.""" - - ADDED = "added" - REMOVED = "removed" - CHANGED = "changed" - - -class DifferenceClassification(StrEnum): - """How the canary changelog accounts for a semantic difference.""" - - EXPECTED = "expected" - UNEXPECTED = "unexpected" - - -def _canonical_identity(identity: tuple[tuple[str, object], ...]) -> tuple[tuple[str, object], ...]: - """Make JSON object key order irrelevant to a difference identity.""" - - return tuple(sorted(identity, key=lambda item: item[0])) - - -class CanaryAuthorityKind(StrEnum): - """Structured authority attached to one reviewed difference.""" - - DELTA = "delta" - SUCCESSOR = "successor" - - -class CanarySelectionError(ValueError): - """The requested representative canary cannot be built from this index.""" - - -class UnclassifiedCanaryDiffError(ValueError): - """A durable report was requested without one review per diff row.""" - - -_GENERATION_ID_PATTERN = re.compile(r"^gen-[0-9]+-[0-9a-f]{8}$") - - -@dataclass(frozen=True, slots=True) -class ExpectedDifference: - """A reviewed change signature that is allowed in a canary report. - - Matching is intentionally structural. A bead or delta declaration names - the affected table and may narrow the signature to an operation and/or a - changed column. Unmatched differences are ``UNEXPECTED`` by default, so - the report cannot contain an unclassified bucket. - """ - - table: str - bead_ref: str - rationale: str - identity: tuple[tuple[str, object], ...] - operations: tuple[DifferenceOperation, ...] = () - columns: tuple[str, ...] = () - - def __post_init__(self) -> None: - if len(self.operations) != 1: - raise ValueError("expected differences require exactly one operation") - if not self.columns: - raise ValueError("expected differences require a non-empty changed-column signature") - if not self.identity: - raise ValueError("expected differences require a bounded row identity") - - def matches( - self, - *, - table: str, - operation: DifferenceOperation, - identity: tuple[tuple[str, object], ...], - changed_columns: tuple[str, ...], - ) -> bool: - if self.table != table: - return False - if operation is not self.operations[0]: - return False - if _canonical_identity(identity) != _canonical_identity(self.identity): - return False - return tuple(changed_columns) == self.columns - - -@dataclass(frozen=True, slots=True) -class DeltaExpectation: - """A row-difference signature authorized by one declared index delta. - - This is the *derived* half of the classifier. ``ExpectedDifference`` is - hand-authored and pins an exact row identity; a delta declaration is written - before the rebuild runs and cannot know which rows it will touch, so it - states a shape instead: table, operations, the columns it may change, and - (for a targeted reprocess) the session scope it is allowed to touch. - - Every narrowing here is load-bearing. ``changed_columns`` must be a - non-empty subset of the declared columns, so a delta that declares one - column cannot absorb a row that also changed a second, undeclared one -- - the same rule :func:`_validate_expected_review_authorities` applies to a - human review, applied automatically at comparison time. - """ - - version: int - table: str - operations: tuple[DifferenceOperation, ...] - columns: tuple[str, ...] - scope: str = "row" - origin: str | None = None - session_ids: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not self.table or not self.operations or not self.columns: - raise ValueError("delta expectations require a table, operation, and changed-column signature") - if self.scope not in ("row", "schema"): - raise ValueError("delta expectations scope must be 'row' or 'schema'") - - def matches( - self, - *, - table: str, - operation: DifferenceOperation, - identity: tuple[tuple[str, object], ...], - changed_columns: tuple[str, ...], - ) -> bool: - if self.table != table or operation not in self.operations: - return False - schema_identity = dict(identity).get("__schema__") in {"table", "column"} - if (self.scope == "schema") != schema_identity: - return False - if not changed_columns or not set(changed_columns) <= set(self.columns): - return False - if self.scope == "schema": - return True - if self.origin is None and not self.session_ids: - return True - session_id = _session_id_from_identity(dict(identity)) - if session_id is None: - return False - if self.origin is not None and not session_id.startswith(f"{self.origin}:"): - return False - return not self.session_ids or session_id in self.session_ids - - -def index_delta_expectations( - source_version: int, - target_version: int, - *, - declarations: object = None, -) -> tuple[DeltaExpectation, ...]: - """Derive the change signatures the crossed index deltas authorize. - - ``source_version`` is the active generation's ``PRAGMA user_version`` and - ``target_version`` is the version the candidate was built at. Only - declarations that actually claim semantic work contribute; a shape-only - delta cannot authorize a semantic row change and its declaration is refused - the chance to try (``IndexDeltaDeclaration.__post_init__`` already forbids - it from carrying canary changes at all). - """ - from polylogue.storage.sqlite.lifecycle import ( - IndexDeltaDeclaration, - index_delta_declarations_between, - ) - - typed = cast("tuple[IndexDeltaDeclaration, ...] | None", declarations) - crossed = index_delta_declarations_between(source_version, target_version, typed) - expectations: list[DeltaExpectation] = [] - for declaration in crossed: - if not (declaration.requires_semantic_reparse or declaration.requires_targeted_reprocess): - continue - scope = declaration.reprocess_scope - for change in declaration.expected_canary_changes: - expectations.append( - DeltaExpectation( - version=declaration.version, - table=change.table, - operations=tuple(DifferenceOperation(value) for value in change.operations), - columns=tuple(change.columns), - scope=change.scope, - origin=None if scope is None or change.scope == "schema" else scope.origin, - session_ids=() if scope is None else tuple(scope.session_ids), - ) - ) - return tuple(expectations) - - -@dataclass(frozen=True, slots=True) -class RowDifference: - """One canonical row-level difference in the canary changelog.""" - - table: str - operation: DifferenceOperation - identity: tuple[tuple[str, object], ...] - before: dict[str, object] | None - after: dict[str, object] | None - changed_columns: tuple[str, ...] - classification: DifferenceClassification - rationale: str - - def to_dict(self) -> dict[str, object]: - return { - "table": self.table, - "operation": self.operation.value, - "identity": dict(self.identity), - "before": self.before, - "after": self.after, - "changed_columns": list(self.changed_columns), - "classification": self.classification.value, - "rationale": self.rationale, - } - - -@dataclass(frozen=True, slots=True) -class CanaryDiffReport: - """Complete, JSON-ready account of a read-model comparison.""" - - current_index: Path - candidate_index: Path - session_ids: tuple[str, ...] - compared_tables: tuple[str, ...] - missing_tables: tuple[str, ...] - missing_columns: tuple[tuple[str, tuple[str, ...]], ...] - differences: tuple[RowDifference, ...] - # Delta provenance for the classifier: the active generation's own index - # version, and the crossed versions that ship no declaration at all. An - # undeclared version authorizes nothing, so its effects land in the - # unexpected bucket -- naming it keeps that a stated gap rather than an - # unexplained pile of rows. - source_index_version: int | None = None - undeclared_delta_versions: tuple[int, ...] = () - - @property - def expected_count(self) -> int: - return sum(item.classification is DifferenceClassification.EXPECTED for item in self.differences) - - @property - def unexpected_count(self) -> int: - return sum(item.classification is DifferenceClassification.UNEXPECTED for item in self.differences) - - @property - def unclassified_count(self) -> int: - """The explicit zero-bucket contract for the canary changelog.""" - - return 0 - - @property - def counts_by_table(self) -> dict[str, int]: - return dict(sorted(Counter(item.table for item in self.differences).items())) - - def to_dict(self) -> dict[str, object]: - return { - "current_index": str(self.current_index), - "candidate_index": str(self.candidate_index), - "session_ids": list(self.session_ids), - "compared_tables": list(self.compared_tables), - "missing_tables": list(self.missing_tables), - "missing_columns": [{"table": table, "columns": list(columns)} for table, columns in self.missing_columns], - "delta_coverage": { - "source_index_version": self.source_index_version, - "undeclared_delta_versions": list(self.undeclared_delta_versions), - }, - "summary": { - "difference_count": len(self.differences), - "expected_count": self.expected_count, - "unexpected_count": self.unexpected_count, - "unclassified_count": self.unclassified_count, - "counts_by_table": self.counts_by_table, - }, - "differences": [item.to_dict() for item in self.differences], - } - - -@dataclass(frozen=True, slots=True) -class CanarySelection: - """Deterministic, read-only input selection for one canary rebuild.""" - - index_path: Path - sessions_per_origin: int - selected_session_ids: tuple[str, ...] - selected_raw_ids: tuple[str, ...] - sampled_session_ids: tuple[str, ...] - pathology_session_ids: tuple[str, ...] - sample_session_ids: tuple[str, ...] - origin_counts: tuple[tuple[str, int], ...] - parser_fingerprints: tuple[tuple[str, str], ...] = () - lowering_fingerprint: str | None = None - replay_routing_fingerprint: str | None = None - materializer_fingerprint: str | None = None - - def to_dict(self) -> dict[str, object]: - return { - "index_path": str(self.index_path), - "sessions_per_origin": self.sessions_per_origin, - "selected_session_ids": list(self.selected_session_ids), - "selected_raw_ids": list(self.selected_raw_ids), - "sampled_session_ids": list(self.sampled_session_ids), - "pathology_session_ids": list(self.pathology_session_ids), - "sample_session_ids": list(self.sample_session_ids), - "origin_counts": dict(self.origin_counts), - "parser_fingerprints": dict(self.parser_fingerprints), - "lowering_fingerprint": self.lowering_fingerprint, - "replay_routing_fingerprint": self.replay_routing_fingerprint, - "materializer_fingerprint": self.materializer_fingerprint, - } - - -@dataclass(frozen=True, slots=True) -class CanaryRunResult: - """Evidence from one bounded inactive-generation canary run.""" - - selection: CanarySelection - comparison: CanaryDiffReport - rebuild_receipt: dict[str, object] - - def to_dict(self) -> dict[str, object]: - return { - "selection": self.selection.to_dict(), - "comparison": self.comparison.to_dict(), - "rebuild_receipt": self.rebuild_receipt, - } - - -def select_canary_sessions( - index_path: Path, - *, - sessions_per_origin: int = 100, - pathology_session_ids: Iterable[str] = (), - sample_session_ids: Iterable[str] = (), - source_root: Path | None = None, -) -> CanarySelection: - """Select a representative raw-id set from a real active index. - - The automatic portion takes the newest deterministic ``N`` sessions for - every origin. Explicit pathology and sample sessions are always included, - even when they fall outside that sample. Every explicit id must resolve to - an indexed session with a non-null ``raw_id`` because the existing rebuild - route accepts raw ids, not summaries or synthetic session descriptions. - """ - - if sessions_per_origin <= 0: - raise CanarySelectionError("sessions_per_origin must be positive") - path = Path(index_path) - pathology = tuple(dict.fromkeys(str(value) for value in pathology_session_ids)) - explicit_samples = tuple(dict.fromkeys(str(value) for value in sample_session_ids)) - explicit = set(pathology).union(explicit_samples) - with _open_read_only(path) as connection: - rows = connection.execute( - """ - SELECT session_id, origin, raw_id, sort_key_ms - FROM sessions - ORDER BY origin, (sort_key_ms IS NULL), sort_key_ms DESC, session_id - """ - ).fetchall() - - records: dict[str, tuple[str, str | None]] = { - str(row["session_id"]): ( - str(row["origin"]), - str(row["raw_id"]) if row["raw_id"] is not None else None, - ) - for row in rows - } - missing = sorted(explicit.difference(records)) - if missing: - raise CanarySelectionError(f"explicit canary session(s) are not indexed: {', '.join(missing)}") - without_raw = sorted(session_id for session_id in explicit if records[session_id][1] is None) - if without_raw: - raise CanarySelectionError( - "explicit canary session(s) have no raw_id and cannot be replayed: " + ", ".join(without_raw) - ) - - sampled: list[str] = [] - origin_seen: dict[str, int] = {} - for row in rows: - if row["raw_id"] is None: - continue - origin = str(row["origin"]) - if origin_seen.get(origin, 0) >= sessions_per_origin: - continue - session_id = str(row["session_id"]) - sampled.append(session_id) - origin_seen[origin] = origin_seen.get(origin, 0) + 1 - - selected = set(sampled).union(explicit) - selected_session_ids = tuple(sorted(selected)) - selected_raw_ids = tuple( - dict.fromkeys(sorted(raw_id for session_id in selected if (raw_id := records[session_id][1]) is not None)) - ) - if not selected_session_ids: - raise CanarySelectionError("automatic canary selection produced zero sessions; refusing full-source replay") - if not selected_raw_ids: - raise CanarySelectionError("automatic canary selection produced zero raw ids; refusing full-source replay") - origin_counts = Counter(records[session_id][0] for session_id in selected) - from polylogue.sources.origin_specs import ( - lowering_fingerprint, - materializer_fingerprint, - parser_fingerprint_for_origin, - replay_routing_fingerprint, - ) - - selected_origins = tuple(sorted(origin_counts)) - parser_fingerprints: tuple[tuple[str, str], ...] = () - lowering: str | None = None - replay_routing: str | None = None - materializer: str | None = None - if (Path(source_root) if source_root is not None else path.parent).joinpath("source.db").exists(): - parser_fingerprints = tuple((origin, parser_fingerprint_for_origin(origin)) for origin in selected_origins) - lowering = lowering_fingerprint() - replay_routing = replay_routing_fingerprint() - materializer = materializer_fingerprint() - return CanarySelection( - index_path=path, - sessions_per_origin=sessions_per_origin, - selected_session_ids=selected_session_ids, - selected_raw_ids=selected_raw_ids, - sampled_session_ids=tuple(sorted(sampled)), - pathology_session_ids=tuple(sorted(pathology)), - sample_session_ids=tuple(sorted(explicit_samples)), - origin_counts=tuple(sorted(origin_counts.items())), - parser_fingerprints=parser_fingerprints, - lowering_fingerprint=lowering, - replay_routing_fingerprint=replay_routing, - materializer_fingerprint=materializer, - ) - - -def run_reindex_canary( - archive_root: Path, - *, - input_index: Path | None = None, - schema_inference_receipt_path: Path | None, - sessions_per_origin: int = 100, - pathology_session_ids: Iterable[str] = (), - sample_session_ids: Iterable[str] = (), - no_promote: bool, -) -> CanaryRunResult: - """Replay a selected canary through the existing inactive rebuild route. - - The schema-inference receipt is explicit because this partial rebuild must - consume the same identity-bound source evidence as a full rebuild. The - canary never falls back to ambient process configuration. - """ - - if schema_inference_receipt_path is None: - raise CanarySelectionError("reindex canary requires an explicit schema-inference receipt path") - if not no_promote: - raise CanarySelectionError("reindex canary requires --no-promote") - from polylogue.storage.archive_identity import ArchiveLocation, TierFileIdentity - - root = Path(archive_root) - current_index = _resolve_canary_input_index(root, input_index) - location = ArchiveLocation.resolve(root) - if input_index is not None: - supplied = TierFileIdentity.resolve("index", Path(input_index)) - if not location.active_index.same_file(supplied): - raise CanarySelectionError( - "input index is not the configured archive active generation; " - "explicit canary input index must be inside or bound to the selected archive root" - ) - from polylogue.maintenance.pathology_zoo import pathology_zoo_is_present, pathology_zoo_session_ids - - automatic_pathology_ids = ( - pathology_zoo_session_ids() if pathology_zoo_is_present(root, index_path_override=current_index) else () - ) - requested_pathology_ids = tuple(dict.fromkeys((*automatic_pathology_ids, *pathology_session_ids))) - selection = select_canary_sessions( - current_index, - sessions_per_origin=sessions_per_origin, - pathology_session_ids=requested_pathology_ids, - sample_session_ids=sample_session_ids, - source_root=root, - ) - _validate_nonempty_selection(selection) - from polylogue.daemon.bulk_rebuild import run_daemon_canary_rebuild - from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION - - receipt: object = run_daemon_canary_rebuild( - archive_root=root, - raw_ids=selection.selected_raw_ids, - selected_session_ids=selection.selected_session_ids, - index_schema_version=INDEX_SCHEMA_VERSION, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - try: - try: - active_after_rebuild = ArchiveLocation.resolve(root).active_index - selected_active_index = TierFileIdentity.resolve("index", current_index) - if not active_after_rebuild.same_file(selected_active_index): - raise CanarySelectionError("canary active index changed during rebuild") - except OSError as exc: - raise CanarySelectionError("canary active index could not be revalidated after rebuild") from exc - if isinstance(receipt, dict): - receipt_payload = receipt - else: - to_dict = getattr(receipt, "to_dict", None) - if not callable(to_dict): - raise CanarySelectionError("daemon canary rebuild did not return a receipt object") - receipt_payload = to_dict() - if not isinstance(receipt_payload, dict): - raise CanarySelectionError("daemon canary rebuild did not return a receipt object") - _validate_selection_evidence( - receipt_payload, - selection.selected_raw_ids, - selected_session_ids=selection.selected_session_ids, - expected_parser_fingerprints=selection.parser_fingerprints, - expected_lowering_fingerprint=selection.lowering_fingerprint, - expected_replay_routing_fingerprint=selection.replay_routing_fingerprint, - expected_materializer_fingerprint=selection.materializer_fingerprint, - ) - candidate_path = _validate_canary_candidate( - root, - current_index=current_index, - selection=selection, - receipt=receipt, - ) - _validate_authoritative_rebuild_receipt(receipt_payload, candidate_path) - from polylogue.storage.sqlite.lifecycle import undeclared_index_delta_versions - - source_index_version = _index_user_version(current_index) - comparison = compare_reindex_generations( - current_index, - candidate_path, - session_ids=selection.selected_session_ids, - delta_expectations=index_delta_expectations(source_index_version, INDEX_SCHEMA_VERSION), - source_index_version=source_index_version, - undeclared_delta_versions=undeclared_index_delta_versions(source_index_version, INDEX_SCHEMA_VERSION), - ) - if comparison.session_ids != selection.selected_session_ids: - raise CanarySelectionError("canary comparator scope does not match the selected sessions") - if not comparison.session_ids: - raise CanarySelectionError("canary comparator scope is empty; refusing an unbounded comparison") - except BaseException as exc: - cleanup_errors = _discard_canary_candidate(root, receipt) - _add_canary_cleanup_notes(exc, cleanup_errors) - raise - return CanaryRunResult( - selection=selection, - comparison=comparison, - rebuild_receipt=receipt_payload, - ) - - -def _index_user_version(path: Path) -> int: - """Read the active generation's own declared index schema version.""" - - with _open_read_only(path) as connection: - row = connection.execute("PRAGMA user_version").fetchone() - if row is None: - raise CanarySelectionError(f"index has no declared schema version: {path}") - return int(row[0]) - - -def _crossed_delta_versions_for_comparison(comparison: CanaryDiffReport) -> frozenset[int] | None: - """Return only delta versions that the compared generations actually cross. - - A reviewed delta is executable authority, not a historical explanation. If - the comparison carries its active source version, derive the target from - the candidate file and restrict authority to that exact version interval. - Older reports without source-version evidence retain their structural - validation path and cannot be approved as current reports. - """ - source_version = comparison.source_index_version - if source_version is None: - return None - try: - persisted_source_version = _index_user_version(comparison.current_index) - except (CanarySelectionError, OSError, sqlite3.Error) as exc: - raise UnclassifiedCanaryDiffError( - "cannot verify persisted current index source version for canary authority" - ) from exc - if persisted_source_version != source_version: - raise UnclassifiedCanaryDiffError("reported source index version does not match persisted current index") - from polylogue.storage.sqlite.lifecycle import index_delta_declarations_between - - target_version = _index_user_version(comparison.candidate_index) - return frozenset( - declaration.version for declaration in index_delta_declarations_between(source_version, target_version) - ) - - -def _discard_canary_candidate(archive_root: Path, receipt: object) -> list[BaseException]: - """Discard the inactive canary candidate after a post-rebuild failure.""" - from polylogue.daemon.bulk_rebuild import discard_daemon_canary_candidate - - errors: list[BaseException] = [] - generation = receipt.get("generation") if isinstance(receipt, dict) else getattr(receipt, "generation", None) - if not isinstance(generation, dict): - return [RuntimeError("canary receipt did not identify a candidate for cleanup")] - generation_id = generation.get("generation_id") - generation_owner_id = generation.get("owner_id") - if ( - not isinstance(generation_id, str) - or not generation_id - or not isinstance(generation_owner_id, str) - or not generation_owner_id - ): - return [RuntimeError("canary receipt candidate generation identity is missing")] - try: - discard_daemon_canary_candidate( - archive_root=archive_root, - generation_id=generation_id, - generation_owner_id=generation_owner_id, - ) - except BaseException as exc: - errors.append(exc) - return errors - - -def _add_canary_cleanup_notes(primary: BaseException, errors: list[BaseException]) -> None: - """Surface candidate cleanup failures without hiding the route failure.""" - if errors: - detail = "; ".join(f"{type(error).__name__}: {error}" for error in errors) - primary.add_note(f"canary candidate cleanup also failed: {detail}") - - -def _resolve_canary_input_index(archive_root: Path, input_index: Path | None) -> Path: - """Resolve an explicit index only when it belongs to the selected archive.""" - from polylogue.storage.archive_identity import ArchiveLocation - - location = ArchiveLocation.resolve(archive_root) - if input_index is None: - return location.active_index_path - - candidate = Path(input_index) - try: - candidate_resolved = candidate.resolve() - archive_resolved = Path(archive_root).resolve() - except (OSError, RuntimeError) as exc: - raise CanarySelectionError("explicit canary input index path cannot be resolved") from exc - - try: - candidate_resolved.relative_to(archive_resolved) - except ValueError: - if candidate_resolved != location.active_index.resolved_path: - raise CanarySelectionError( - "input index is not the configured archive active generation; " - "explicit canary input index must be inside or bound to the selected archive root" - ) from None - return candidate - - -def _validate_canary_candidate( - archive_root: Path, - *, - current_index: Path, - selection: CanarySelection, - receipt: object, -) -> Path: - """Prove the compared index is this run's own inactive generation.""" - from polylogue.storage.archive_identity import ArchiveLocation - - root = archive_root.resolve() - - def field(name: str) -> object: - return receipt.get(name) if isinstance(receipt, dict) else getattr(receipt, name, None) - - receipt_root = field("archive_root") - if not isinstance(receipt_root, str) or Path(receipt_root).resolve() != root: - raise CanarySelectionError("rebuild receipt belongs to a different archive root") - if field("status") != "replayed" or field("materialized") is not True: - raise CanarySelectionError("rebuild receipt is not a completed materialized replay") - if field("selected_raw_count") != len(selection.selected_raw_ids): - raise CanarySelectionError("rebuild receipt selected a different raw-id set than the canary") - - generation = field("generation") - if not isinstance(generation, dict): - raise CanarySelectionError("rebuild receipt did not identify an inactive candidate generation") - if generation.get("archive_root") != str(root): - raise CanarySelectionError("candidate generation belongs to a different archive root") - if generation.get("state") != "inactive": - raise CanarySelectionError("reindex canary candidate must remain an inactive generation") - generation_id = generation.get("generation_id") - owner_id = generation.get("owner_id") - source_snapshot = generation.get("source_snapshot") - candidate_value = generation.get("index_path") - if not all( - isinstance(value, str) and value for value in (generation_id, owner_id, source_snapshot, candidate_value) - ): - raise CanarySelectionError("rebuild receipt did not identify a complete inactive candidate generation") - assert isinstance(candidate_value, str) - - location = ArchiveLocation.resolve(archive_root) - anchor = location.active_pointer or location.configured_tier("index").configured_path - expected_generation_root = anchor.parent / ".index-generations" - candidate_path = Path(candidate_value) - try: - candidate_resolved = candidate_path.resolve(strict=True) - current_resolved = Path(current_index).resolve(strict=True) - except OSError as exc: - raise CanarySelectionError("rebuild receipt candidate index is not readable") from exc - if candidate_resolved == current_resolved: - raise CanarySelectionError("rebuild receipt candidate index is the active index") - if ( - candidate_resolved.name != "index.db" - or candidate_resolved.parent.name != generation_id - or candidate_resolved.parent.parent != expected_generation_root.resolve() - ): - raise CanarySelectionError("rebuild receipt candidate is outside this archive's generation root") - return candidate_path - - -def _validate_nonempty_selection(selection: CanarySelection) -> None: - """Keep the canary route from inheriting the rebuild engine's full-source default.""" - - if not selection.selected_session_ids: - raise CanarySelectionError("canary selection contains zero sessions; refusing full-source replay") - if not selection.selected_raw_ids: - raise CanarySelectionError("canary selection contains zero raw ids; refusing full-source replay") - if len(set(selection.selected_session_ids)) != len(selection.selected_session_ids): - raise CanarySelectionError("canary selection contains duplicate session ids") - if len(set(selection.selected_raw_ids)) != len(selection.selected_raw_ids): - raise CanarySelectionError("canary selection contains duplicate raw ids") - - -@dataclass(frozen=True, slots=True) -class CanaryDifferenceReview: - """An explicit operator classification for one diff row.""" - - table: str - operation: DifferenceOperation - identity: tuple[tuple[str, object], ...] - changed_columns: tuple[str, ...] - classification: DifferenceClassification - reference: str - rationale: str - authority_kind: CanaryAuthorityKind | None = None - authority_id: str | None = None - - def __post_init__(self) -> None: - kind = self.authority_kind - authority_id = self.authority_id - has_explicit_authority = kind is not None or authority_id is not None - if (kind is None) != (authority_id is None): - raise UnclassifiedCanaryDiffError("canary review authority must provide both kind and id") - if kind is None or authority_id is None: - prefix, separator, value = self.reference.partition(":") - if separator: - try: - kind = CanaryAuthorityKind(prefix) - except ValueError as exc: - raise UnclassifiedCanaryDiffError("canary review reference has an invalid authority kind") from exc - authority_id = value - elif self.classification is DifferenceClassification.UNEXPECTED: - # Preserve compatibility for in-process callers. The durable - # representation below is always structured. - kind = CanaryAuthorityKind.SUCCESSOR - authority_id = self.reference - else: - raise UnclassifiedCanaryDiffError( - "expected canary differences require an explicit declared delta authority" - ) - if not str(authority_id).strip() or any(character.isspace() for character in str(authority_id)): - raise UnclassifiedCanaryDiffError("canary review authority must have a structured non-empty id") - canonical_reference = f"{kind.value}:{authority_id}" - if has_explicit_authority and self.reference != canonical_reference: - raise UnclassifiedCanaryDiffError("canary review reference disagrees with its structured authority") - if self.classification is DifferenceClassification.EXPECTED and kind is not CanaryAuthorityKind.DELTA: - raise UnclassifiedCanaryDiffError("expected canary differences require a declared delta authority") - if self.classification is DifferenceClassification.UNEXPECTED and kind is not CanaryAuthorityKind.SUCCESSOR: - raise UnclassifiedCanaryDiffError("unexpected canary differences require a structured successor id") - object.__setattr__(self, "authority_kind", kind) - object.__setattr__(self, "authority_id", str(authority_id)) - object.__setattr__(self, "reference", canonical_reference) - - @classmethod - def for_difference( - cls, - difference: RowDifference, - *, - classification: DifferenceClassification, - reference: str, - rationale: str, - ) -> CanaryDifferenceReview: - return cls( - table=difference.table, - operation=difference.operation, - identity=difference.identity, - changed_columns=difference.changed_columns, - classification=classification, - reference=reference, - rationale=rationale, - ) - - @property - def key(self) -> tuple[str, DifferenceOperation, tuple[tuple[str, object], ...], tuple[str, ...]]: - return self.table, self.operation, _canonical_identity(self.identity), self.changed_columns - - def to_dict(self) -> dict[str, object]: - authority_kind = self.authority_kind - assert authority_kind is not None - return { - "table": self.table, - "operation": self.operation.value, - "identity": dict(self.identity), - "changed_columns": list(self.changed_columns), - "classification": self.classification.value, - "reference": self.reference, - "authority": {"kind": authority_kind.value, "id": self.authority_id}, - "rationale": self.rationale, - } - - -LEGACY_CANARY_REPORT_SCHEMA_VERSION = 9 -STRICT_CANARY_REPORT_SCHEMA_VERSION = 10 -CANARY_REPORT_SCHEMA_VERSION = 11 -CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION = 1 -_CANARY_COMPARISON_ATTESTATION_NAME = "canary-comparison-attestation.json" - - -@dataclass(frozen=True, slots=True) -class CanaryComparisonAttestation: - """Daemon-sealed historical comparison evidence owned by one candidate. - - The sidecar is deliberately separate from the externally writable report: - its path is derived from the candidate generation and it is created once by - the daemon while it owns the archive. A later active-index fast-forward - must not turn a correctly sealed historical comparison into a different - comparison, nor may it be silently rebased onto the new active bytes. - """ - - payload: dict[str, object] - - def to_dict(self) -> dict[str, object]: - return self.payload - - @property - def digest(self) -> str: - return _canonical_payload_digest(self.payload) - - -@dataclass(frozen=True, slots=True) -class DurableCanaryReport: - """The reviewed, persisted canary changelog.""" - - selection: CanarySelection - comparison: CanaryDiffReport - rebuild_receipt: dict[str, object] - reviews: tuple[CanaryDifferenceReview, ...] - review_status: str - comparison_fingerprint: str - archive_provenance: dict[str, object] - comparison_attestation: dict[str, object] | None = None - - @property - def unclassified_count(self) -> int: - return self.comparison.unclassified_count - - def to_dict(self) -> dict[str, object]: - payload: dict[str, object] = { - "schema_version": ( - CANARY_REPORT_SCHEMA_VERSION - if self.comparison_attestation is not None - else STRICT_CANARY_REPORT_SCHEMA_VERSION - ), - "selection": self.selection.to_dict(), - "comparison": self.comparison.to_dict(), - "rebuild_receipt": self.rebuild_receipt, - "reviews": [review.to_dict() for review in self.reviews], - "review_status": self.review_status, - "comparison_fingerprint": self.comparison_fingerprint, - "archive_provenance": self.archive_provenance, - } - if self.comparison_attestation is not None: - payload["comparison_attestation"] = self.comparison_attestation - return payload - - -def write_canary_report( - output_path: Path, - *, - selection: CanarySelection, - comparison: CanaryDiffReport, - rebuild_receipt: dict[str, object], - reviews: Iterable[CanaryDifferenceReview], - allow_unreviewed: bool = False, - comparison_attestation: CanaryComparisonAttestation | None = None, -) -> DurableCanaryReport: - """Persist reviewed evidence, or one explicitly non-approvable discovery report. - - Reviews must cover exactly the comparator's row identities and signatures. - This prevents a report from becoming a durable green-light artifact while a diff was - silently omitted from review. The write is atomic and touches only the - requested report path, never either SQLite generation. - """ - - _validate_rebuild_receipt( - rebuild_receipt, - selected_raw_ids=selection.selected_raw_ids, - selected_session_ids=selection.selected_session_ids, - candidate_index=comparison.candidate_index, - ) - _validate_selection_binding(selection, comparison, rebuild_receipt) - if comparison_attestation is not None: - _validate_comparison_attestation_for_report( - comparison_attestation.payload, - comparison=comparison, - selection=selection, - receipt=rebuild_receipt, - configured_archive_root=Path(cast(str, rebuild_receipt["archive_root"])), - ) - review_list = tuple(reviews) - review_by_key: dict[ - tuple[str, DifferenceOperation, tuple[tuple[str, object], ...], tuple[str, ...]], CanaryDifferenceReview - ] = {} - duplicate_keys: list[object] = [] - for review in review_list: - if not review.reference.strip() or not review.rationale.strip(): - raise UnclassifiedCanaryDiffError("every canary review needs a non-empty reference and rationale") - if review.key in review_by_key: - duplicate_keys.append(review.key) - review_by_key[review.key] = review - _validate_expected_review_authorities( - review_list, - crossed_delta_versions=_crossed_delta_versions_for_comparison(comparison), - require_crossed_delta_versions=True, - ) - difference_keys = {_difference_key(difference) for difference in comparison.differences} - missing_keys = difference_keys.difference(review_by_key) - extra_keys = set(review_by_key).difference(difference_keys) - incomplete = bool(duplicate_keys or missing_keys or extra_keys) - if incomplete and not (allow_unreviewed and not review_list): - detail = [ - f"duplicate={len(duplicate_keys)}", - f"missing={len(missing_keys)}", - f"extra={len(extra_keys)}", - ] - raise UnclassifiedCanaryDiffError("canary report classification is incomplete (" + ", ".join(detail) + ")") - review_status = "unreviewed" if incomplete else "reviewed" - - archive_provenance = _capture_archive_provenance(comparison, rebuild_receipt) - if review_status == "reviewed": - reviewed_differences = tuple( - replace( - difference, - classification=review_by_key[_difference_key(difference)].classification, - rationale=_reviewed_difference_rationale(review_by_key[_difference_key(difference)]), - ) - for difference in comparison.differences - ) - reviewed_comparison = replace(comparison, differences=reviewed_differences) - else: - reviewed_comparison = comparison - durable = DurableCanaryReport( - selection=selection, - comparison=reviewed_comparison, - rebuild_receipt=rebuild_receipt, - reviews=review_list, - review_status=review_status, - comparison_fingerprint=_comparison_fingerprint(comparison), - archive_provenance=archive_provenance, - comparison_attestation=( - _comparison_attestation_reference(comparison_attestation) if comparison_attestation is not None else None - ), - ) - path = Path(output_path) - path.parent.mkdir(parents=True, exist_ok=True) - payload = json.dumps(durable.to_dict(), ensure_ascii=False, indent=2, sort_keys=True) - temporary: Path | None = None - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - delete=False, - ) as stream: - temporary = Path(stream.name) - stream.write(payload) - stream.write("\n") - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, path) - temporary = None - _fsync_directory(path.parent) - except BaseException as exc: - if temporary is not None: - try: - temporary.unlink() - except BaseException as cleanup_error: - exc.add_note( - f"canary report temporary-file cleanup also failed: {type(cleanup_error).__name__}: {cleanup_error}" - ) - raise - return durable - - -def _canonical_payload_digest(payload: object) -> str: - """Return the stable digest used to bind an external report to its sidecar.""" - - encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") - return sha256(encoded).hexdigest() - - -def _comparison_attestation_path(candidate_index: Path) -> Path: - """Derive the sole archive-owned comparison sidecar path for a candidate.""" - - return Path(candidate_index).parent / _CANARY_COMPARISON_ATTESTATION_NAME - - -def _comparison_attestation_reference(attestation: CanaryComparisonAttestation) -> dict[str, object]: - payload = attestation.payload - candidate = payload.get("candidate_generation") - if not isinstance(candidate, dict): - raise UnclassifiedCanaryDiffError("canary comparison attestation has no candidate identity") - generation_id = candidate.get("generation_id") - owner_id = candidate.get("owner_id") - if not isinstance(generation_id, str) or not generation_id or not isinstance(owner_id, str) or not owner_id: - raise UnclassifiedCanaryDiffError("canary comparison attestation has incomplete candidate identity") - return { - "schema_version": CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION, - "generation_id": generation_id, - "owner_id": owner_id, - "digest": attestation.digest, - } - - -def _write_immutable_json(path: Path, payload: dict[str, object]) -> None: - """Create a sidecar exactly once; replacing a seal would rewrite authority.""" - - encoded = (json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") - descriptor = -1 - temporary_path: Path | None = None - try: - descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent) - temporary_path = Path(temporary_name) - with os.fdopen(descriptor, "wb") as handle: - descriptor = -1 - handle.write(encoded) - handle.flush() - os.fsync(handle.fileno()) - os.link(temporary_path, path) - temporary_path.unlink() - temporary_path = None - finally: - if descriptor != -1: - os.close(descriptor) - if temporary_path is not None: - temporary_path.unlink(missing_ok=True) - _fsync_directory(path.parent) - - -def _attestation_selection_digest(evidence: object) -> str: - """Bind the receipt's canonical selection commitment without re-expanding it.""" - - if not isinstance(evidence, dict): - raise UnclassifiedCanaryDiffError("canary comparison receipt has no selection evidence") - raw_ids_sha256 = evidence.get("raw_ids_sha256") - raw_id_count = evidence.get("raw_id_count") - selected_session_ids = evidence.get("selected_session_ids") - if ( - not isinstance(raw_ids_sha256, str) - or len(raw_ids_sha256) != 64 - or not isinstance(raw_id_count, int) - or not isinstance(selected_session_ids, list) - or not all(isinstance(value, str) and value for value in selected_session_ids) - ): - raise UnclassifiedCanaryDiffError("canary comparison receipt has invalid selection evidence") - return _canonical_payload_digest( - { - "raw_ids_sha256": raw_ids_sha256, - "raw_id_count": raw_id_count, - "selected_session_ids": selected_session_ids, - } - ) - - -def seal_canary_comparison_under_daemon_ownership( - *, archive_root: Path, generation_id: str, generation_owner_id: str -) -> CanaryComparisonAttestation: - """Seal a candidate's historical comparison while the daemon owns writes.""" - - from polylogue.daemon.write_coordinator import daemon_write_lease_active - - if not daemon_write_lease_active(): - raise RuntimeError("canary comparison sealing requires the daemon write coordinator") - return seal_canary_comparison( - archive_root=archive_root, - generation_id=generation_id, - generation_owner_id=generation_owner_id, - ) - - -def _selected_raw_ids_from_baseline(index_path: Path, session_ids: tuple[str, ...]) -> tuple[str, ...]: - """Recover the caller selection from the baseline the daemon is about to seal.""" - - placeholders = ",".join("?" for _ in session_ids) - with _open_read_only(index_path) as connection: - rows = connection.execute( - f"SELECT raw_id FROM sessions WHERE session_id IN ({placeholders}) AND raw_id IS NOT NULL", - session_ids, - ).fetchall() - raw_ids = tuple(sorted({str(row[0]) for row in rows})) - if not raw_ids: - raise UnclassifiedCanaryDiffError("canary comparison selection has no baseline raw ids") - return raw_ids - - -def seal_canary_comparison( - *, archive_root: Path, generation_id: str, generation_owner_id: str -) -> CanaryComparisonAttestation: - """Recompute and immutably seal one inactive candidate comparison. - - This is intentionally candidate-generation owned. The active index is - consulted only at sealing time; later validation authenticates the sealed - baseline evidence instead of treating present active bytes as history. - """ - - from polylogue.storage.archive_identity import ArchiveLocation - from polylogue.storage.sqlite.lifecycle import undeclared_index_delta_versions - - root = Path(archive_root).resolve() - location = ArchiveLocation.resolve(root) - candidate_metadata = _read_generation_metadata(_generation_root(location), generation_id) - candidate = _generation_fields(candidate_metadata) - if candidate["owner_id"] != generation_owner_id or candidate["state"] != "inactive": - raise UnclassifiedCanaryDiffError("canary comparison candidate ownership or state does not match") - if Path(cast(str, candidate["archive_root"])).resolve() != root: - raise UnclassifiedCanaryDiffError("canary comparison candidate belongs to a different archive root") - candidate_index = Path(cast(str, candidate["index_path"])) - expected_candidate = _generation_root(location).resolve() / generation_id / "index.db" - if candidate_index.resolve(strict=True) != expected_candidate: - raise UnclassifiedCanaryDiffError("canary comparison candidate path is not archive-owned") - receipt_path = candidate_index.parent / "rebuild-receipt.json" - try: - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt is unreadable") from exc - if not isinstance(receipt, dict) or receipt.get("generation") != candidate_metadata: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt does not match the candidate") - evidence = receipt.get("selection_evidence") - if not isinstance(evidence, dict): - raise UnclassifiedCanaryDiffError("canary comparison receipt has no selection evidence") - session_ids = evidence.get("selected_session_ids") - if ( - not isinstance(session_ids, list) - or not session_ids - or not all(isinstance(value, str) and value for value in session_ids) - ): - raise UnclassifiedCanaryDiffError("canary comparison receipt has invalid selection evidence") - selected_session_ids = tuple(cast(list[str], session_ids)) - selected_raw_ids = _selected_raw_ids_from_baseline(location.active_index_path, selected_session_ids) - _validate_rebuild_receipt( - receipt, - selected_raw_ids=selected_raw_ids, - selected_session_ids=selected_session_ids, - candidate_index=candidate_index, - configured_archive_root=root, - ) - _validate_authoritative_rebuild_receipt(receipt, candidate_index) - source_snapshot = _verified_source_evidence(root) - if source_snapshot != candidate["source_snapshot"] or source_snapshot != receipt.get("source_evidence_after"): - raise UnclassifiedCanaryDiffError("archive-owned source evidence does not match the inactive candidate") - parser_fingerprints, lowering, routing, materializer = _parser_binding_from_evidence(evidence) - _validate_live_parser_and_lowering_fingerprints(parser_fingerprints, lowering) - _validate_live_replay_routing_fingerprint(routing) - _validate_live_materializer_fingerprint(materializer) - baseline_index = location.active_index_path - from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION - - source_version = _index_user_version(baseline_index) - candidate_file_version = _index_user_version(candidate_index) - # Rebuild candidates may retain the source PRAGMA while their replay - # semantics target the packaged index declaration. The historical authority - # interval is therefore the daemon's target version, while the sidecar also - # records the candidate file's own observed version for byte evidence. - target_version = INDEX_SCHEMA_VERSION - comparison = compare_reindex_generations( - baseline_index, - candidate_index, - session_ids=selected_session_ids, - delta_expectations=index_delta_expectations(source_version, target_version), - source_index_version=source_version, - undeclared_delta_versions=undeclared_index_delta_versions(source_version, target_version), - ) - if comparison.session_ids != selected_session_ids: - raise UnclassifiedCanaryDiffError("canary comparison scope does not match the candidate selection") - crossed = _crossed_delta_versions_between(source_version, target_version) - payload: dict[str, object] = { - "schema_version": CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION, - "archive_root": str(root), - "candidate_generation": candidate_metadata, - "receipt_digest": _canonical_payload_digest(receipt), - "selection_digest": _attestation_selection_digest(evidence), - "source_snapshot": source_snapshot, - "source_evidence_after": receipt.get("source_evidence_after"), - "code_evidence": { - "parser_fingerprints": dict(parser_fingerprints), - "lowering_fingerprint": lowering, - "replay_routing_fingerprint": routing, - "materializer_fingerprint": materializer, - }, - "baseline_index": {"evidence": _index_evidence(baseline_index), "index_schema_version": source_version}, - "candidate_index": { - "evidence": _index_evidence(candidate_index), - "index_schema_version": candidate_file_version, - "comparison_target_index_version": target_version, - }, - "crossed_delta_versions": list(crossed), - "undeclared_delta_versions": list(comparison.undeclared_delta_versions), - "comparison_fingerprint_schema_version": CANARY_REPORT_SCHEMA_VERSION, - "comparison_fingerprint": _comparison_fingerprint(comparison), - "comparison": comparison.to_dict(), - } - path = _comparison_attestation_path(candidate_index) - if path.exists(): - existing = _load_comparison_attestation(path) - if existing.payload != payload: - raise UnclassifiedCanaryDiffError("candidate already has a different immutable comparison attestation") - return existing - try: - _write_immutable_json(path, payload) - except FileExistsError as exc: - existing = _load_comparison_attestation(path) - if existing.payload != payload: - raise UnclassifiedCanaryDiffError( - "candidate already has a different immutable comparison attestation" - ) from exc - return existing - return CanaryComparisonAttestation(payload) - - -def _crossed_delta_versions_between(source_version: int, target_version: int) -> tuple[int, ...]: - from polylogue.storage.sqlite.lifecycle import index_delta_declarations_between - - return tuple( - declaration.version for declaration in index_delta_declarations_between(source_version, target_version) - ) - - -def _load_comparison_attestation(path: Path) -> CanaryComparisonAttestation: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise UnclassifiedCanaryDiffError("archive-owned comparison attestation is unreadable") from exc - if not isinstance(payload, dict) or payload.get("schema_version") != CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION: - raise UnclassifiedCanaryDiffError("archive-owned comparison attestation has an unsupported schema") - return CanaryComparisonAttestation(payload) - - -def _validate_comparison_attestation_for_report( - attestation: dict[str, object], - *, - comparison: CanaryDiffReport, - selection: CanarySelection, - receipt: dict[str, object], - configured_archive_root: Path, -) -> None: - """Validate a v11 report against its fixed archive-owned sidecar. - - Unlike strict legacy reports, this authenticates the historical baseline - through the immutable seal and therefore does not compare it to today's - active pointer or bytes. - """ - - if attestation.get("schema_version") != CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION: - raise UnclassifiedCanaryDiffError("canary comparison attestation has an unsupported schema") - candidate_generation = attestation.get("candidate_generation") - if not isinstance(candidate_generation, dict) or candidate_generation != receipt.get("generation"): - raise UnclassifiedCanaryDiffError("canary comparison attestation candidate does not match the rebuild receipt") - candidate = _generation_fields(candidate_generation) - root = configured_archive_root.resolve() - if attestation.get("archive_root") != str(root): - raise UnclassifiedCanaryDiffError("canary comparison attestation belongs to a different archive root") - if Path(cast(str, candidate["archive_root"])).resolve() != root: - raise UnclassifiedCanaryDiffError("canary comparison attestation candidate belongs to a different archive root") - candidate_index = Path(cast(str, candidate["index_path"])) - if candidate_index.resolve(strict=True) != comparison.candidate_index.resolve(strict=True): - raise UnclassifiedCanaryDiffError("canary comparison attestation candidate does not match the report") - if attestation.get("receipt_digest") != _canonical_payload_digest(receipt): - raise UnclassifiedCanaryDiffError("canary comparison attestation receipt does not match the report") - if attestation.get("selection_digest") != _attestation_selection_digest(receipt.get("selection_evidence")): - raise UnclassifiedCanaryDiffError("canary comparison attestation selection does not match the report") - if attestation.get("comparison_fingerprint_schema_version") != CANARY_REPORT_SCHEMA_VERSION: - raise UnclassifiedCanaryDiffError("canary comparison attestation has an unsupported comparison fingerprint") - if attestation.get("comparison_fingerprint") != _comparison_fingerprint(comparison): - raise UnclassifiedCanaryDiffError("canary comparison attestation does not match the report comparison") - - -def _validate_sealed_comparison_attestation( - reference: object, - *, - comparison: CanaryDiffReport, - selection: CanarySelection, - receipt: dict[str, object], - configured_archive_root: Path, -) -> CanaryComparisonAttestation: - """Load the fixed candidate sidecar and prove it is still authoritative.""" - - if not isinstance(reference, dict): - raise UnclassifiedCanaryDiffError("canary report has no comparison attestation reference") - if reference.get("schema_version") != CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION: - raise UnclassifiedCanaryDiffError("canary report has an unsupported comparison attestation reference") - generation = receipt.get("generation") - if not isinstance(generation, dict): - raise UnclassifiedCanaryDiffError("canary report has invalid candidate generation provenance") - fields = _generation_fields(generation) - if reference.get("generation_id") != fields["generation_id"] or reference.get("owner_id") != fields["owner_id"]: - raise UnclassifiedCanaryDiffError("canary report comparison attestation identity does not match the candidate") - candidate_index = Path(cast(str, fields["index_path"])) - attestation = _load_comparison_attestation(_comparison_attestation_path(candidate_index)) - if reference.get("digest") != attestation.digest: - raise UnclassifiedCanaryDiffError( - "canary report comparison attestation digest does not match the archive-owned sidecar" - ) - _validate_comparison_attestation_for_report( - attestation.payload, - comparison=comparison, - selection=selection, - receipt=receipt, - configured_archive_root=configured_archive_root, - ) - root = configured_archive_root.resolve() - from polylogue.storage.archive_identity import ArchiveLocation - - location = ArchiveLocation.resolve(root) - live_generation = _read_generation_metadata(_generation_root(location), cast(str, fields["generation_id"])) - live_fields = _generation_fields(live_generation) - if live_generation != generation or live_fields["state"] != "inactive": - raise UnclassifiedCanaryDiffError( - "archive-owned candidate generation no longer matches the comparison attestation" - ) - expected_candidate = _generation_root(location).resolve() / str(fields["generation_id"]) / "index.db" - if candidate_index.resolve(strict=True) != expected_candidate: - raise UnclassifiedCanaryDiffError("archive-owned candidate generation path is not archive-owned") - candidate_evidence = attestation.payload.get("candidate_index") - if not isinstance(candidate_evidence, dict) or candidate_evidence.get("evidence") != _index_evidence( - candidate_index - ): - raise UnclassifiedCanaryDiffError( - "archive-owned candidate index bytes no longer match the comparison attestation" - ) - if candidate_evidence.get("index_schema_version") != _index_user_version(candidate_index): - raise UnclassifiedCanaryDiffError( - "archive-owned candidate index version no longer matches the comparison attestation" - ) - stored_receipt_path = candidate_index.parent / "rebuild-receipt.json" - try: - stored_receipt = json.loads(stored_receipt_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt is unreadable") from exc - if stored_receipt != receipt: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt does not match the report") - source_snapshot = _verified_source_evidence(root) - if source_snapshot != attestation.payload.get("source_snapshot") or source_snapshot != fields["source_snapshot"]: - raise UnclassifiedCanaryDiffError("archive-owned source evidence no longer matches the comparison attestation") - code_evidence = attestation.payload.get("code_evidence") - parsers, lowering, routing, materializer = _parser_binding_from_evidence(receipt.get("selection_evidence")) - expected_code_evidence = { - "parser_fingerprints": dict(parsers), - "lowering_fingerprint": lowering, - "replay_routing_fingerprint": routing, - "materializer_fingerprint": materializer, - } - if code_evidence != expected_code_evidence: - raise UnclassifiedCanaryDiffError( - "canary comparison attestation code evidence does not match the rebuild receipt" - ) - _validate_live_parser_and_lowering_fingerprints(parsers, lowering) - _validate_live_replay_routing_fingerprint(routing) - _validate_live_materializer_fingerprint(materializer) - baseline = attestation.payload.get("baseline_index") - if not isinstance(baseline, dict) or not isinstance(baseline.get("index_schema_version"), int): - raise UnclassifiedCanaryDiffError("canary comparison attestation has invalid baseline index evidence") - target_version = candidate_evidence.get("comparison_target_index_version") - if not isinstance(target_version, int): - raise UnclassifiedCanaryDiffError("canary comparison attestation has invalid target version evidence") - crossed = _crossed_delta_versions_between(cast(int, baseline["index_schema_version"]), target_version) - recorded_crossed = attestation.payload.get("crossed_delta_versions") - if not isinstance(recorded_crossed, list) or not all(isinstance(version, int) for version in recorded_crossed): - raise UnclassifiedCanaryDiffError("canary comparison attestation has invalid crossed delta evidence") - if tuple(cast(list[int], recorded_crossed)) != crossed: - raise UnclassifiedCanaryDiffError( - "canary comparison attestation crossed delta evidence does not match sealed versions" - ) - # `comparison` is sealed history. Deliberately do not inspect the current - # active index here: it may have fast-forwarded after the daemon sealed it. - return attestation - - -def _validate_selection_binding( - selection: CanarySelection, - comparison: CanaryDiffReport, - receipt: dict[str, object], - *, - archive_root: Path | None = None, -) -> None: - _validate_nonempty_selection(selection) - if selection.index_path.resolve() != comparison.current_index.resolve(): - raise UnclassifiedCanaryDiffError("canary report selection index does not match the compared current index") - if not comparison.session_ids: - raise UnclassifiedCanaryDiffError("canary report has an empty comparison scope") - if selection.selected_session_ids != comparison.session_ids: - raise UnclassifiedCanaryDiffError("canary report selection sessions do not match the comparison") - _validate_selection_evidence( - receipt, - selection.selected_raw_ids, - selected_session_ids=selection.selected_session_ids, - archive_root=archive_root, - expected_parser_fingerprints=selection.parser_fingerprints, - expected_lowering_fingerprint=selection.lowering_fingerprint, - expected_replay_routing_fingerprint=selection.replay_routing_fingerprint, - expected_materializer_fingerprint=selection.materializer_fingerprint, - ) - - -def _validate_selection_evidence( - receipt: dict[str, object], - selected_raw_ids: Iterable[str], - *, - selected_session_ids: Iterable[str] = (), - archive_root: Path | None = None, - expected_parser_fingerprints: Iterable[tuple[str, str]] = (), - expected_lowering_fingerprint: str | None = None, - expected_replay_routing_fingerprint: str | None = None, - expected_materializer_fingerprint: str | None = None, -) -> None: - """Match report selection to the rebuild-owned candidate commitment.""" - - from polylogue.maintenance.rebuild_index import rebuild_selection_evidence - from polylogue.storage.sqlite.archive_tiers.revision_governance import FrozenSourceRemediationRequiredError - - generation = receipt.get("generation") - evidence = receipt.get("selection_evidence") - if not isinstance(generation, dict) or not isinstance(evidence, dict): - raise UnclassifiedCanaryDiffError("canary report has no authoritative rebuild selection evidence") - required = ( - generation.get("generation_id"), - generation.get("owner_id"), - generation.get("index_path"), - generation.get("source_snapshot"), - receipt.get("archive_root"), - ) - if not all(isinstance(value, str) and value for value in required): - raise UnclassifiedCanaryDiffError("canary report has incomplete authoritative rebuild selection evidence") - receipt_archive_root = cast(str, receipt["archive_root"]) - evidence_root = Path(receipt_archive_root) - if archive_root is not None: - evidence_root = archive_root.resolve() - if Path(receipt_archive_root).resolve() != evidence_root: - raise UnclassifiedCanaryDiffError( - "canary report rebuild receipt belongs to a different configured archive root" - ) - try: - expected = rebuild_selection_evidence( - tuple(selected_raw_ids), - archive_root=evidence_root, - generation_id=cast(str, generation["generation_id"]), - generation_owner_id=cast(str, generation["owner_id"]), - candidate_index=Path(cast(str, generation["index_path"])), - source_snapshot=cast(str, generation["source_snapshot"]), - selected_session_ids=tuple(selected_session_ids), - ) - except FrozenSourceRemediationRequiredError as exc: - raise UnclassifiedCanaryDiffError( - "canary report selection evidence cannot be recomputed after source remediation changed" - ) from exc - if evidence.get("selected_session_ids") != expected["selected_session_ids"]: - raise UnclassifiedCanaryDiffError( - "canary report selection sessions do not match the authoritative rebuild receipt" - ) - if evidence != expected: - raise UnclassifiedCanaryDiffError("canary report selection does not match the authoritative rebuild receipt") - _validate_parser_binding( - evidence, - expected_parser_fingerprints=expected_parser_fingerprints, - expected_lowering_fingerprint=expected_lowering_fingerprint, - expected_replay_routing_fingerprint=expected_replay_routing_fingerprint, - expected_materializer_fingerprint=expected_materializer_fingerprint, - ) - _validate_canary_acceptance_evidence(receipt) - _validate_live_replay_routing_fingerprint(expected_replay_routing_fingerprint) - _validate_live_materializer_fingerprint(expected_materializer_fingerprint) - _validate_live_parser_and_lowering_fingerprints(expected_parser_fingerprints, expected_lowering_fingerprint) - - -def _validate_canary_acceptance_evidence(receipt: dict[str, object]) -> None: - """Require the running daemon's full canonical canary-profile attestation.""" - - from polylogue.maintenance.archive_verification import archive_verification_names_for_route - - acceptance = receipt.get("canary_acceptance") - if not isinstance(acceptance, dict): - raise UnclassifiedCanaryDiffError("canary report has no daemon acceptance-profile attestation") - if acceptance.get("profile") != "reindex-canary-v2-domain-coverage": - raise UnclassifiedCanaryDiffError("canary report acceptance profile does not match the running daemon") - results = acceptance.get("results") - if not isinstance(results, list): - raise UnclassifiedCanaryDiffError("canary report acceptance attestation has no per-check results") - expected_names = list(archive_verification_names_for_route("reindex-canary-candidate")) - actual_names: list[str] = [] - for result in results: - if not isinstance(result, dict): - raise UnclassifiedCanaryDiffError("canary report acceptance attestation has invalid per-check results") - name = result.get("name") - status = result.get("status") - if not isinstance(name, str) or not isinstance(status, str): - raise UnclassifiedCanaryDiffError("canary report acceptance attestation has invalid per-check results") - actual_names.append(name) - if status != "ok": - raise UnclassifiedCanaryDiffError(f"canary report acceptance check {name} is not ok") - if actual_names != expected_names: - raise UnclassifiedCanaryDiffError("canary report acceptance attestation does not match the running profile") - - -def _validate_live_replay_routing_fingerprint(expected: str | None) -> None: - """Reject a persisted canary when installed replay routing has changed.""" - - if expected is None: - return - from polylogue.sources.origin_specs import replay_routing_fingerprint - - if replay_routing_fingerprint() != expected: - raise UnclassifiedCanaryDiffError("canary replay routing fingerprint no longer matches the running code") - - -def _validate_live_materializer_fingerprint(expected: str | None) -> None: - """Reject a persisted canary when installed materialization has changed.""" - - if expected is None: - return - from polylogue.sources.origin_specs import materializer_fingerprint - - if materializer_fingerprint() != expected: - raise UnclassifiedCanaryDiffError("canary materializer fingerprint no longer matches the running code") - - -def _validate_live_parser_and_lowering_fingerprints( - expected_parsers: Iterable[tuple[str, str]], expected_lowering: str | None -) -> None: - """Reject reports whose parser or lowering semantics differ from live code.""" - from polylogue.sources.origin_specs import lowering_fingerprint, parser_fingerprint_for_origin - - if expected_lowering is not None and lowering_fingerprint() != expected_lowering: - raise UnclassifiedCanaryDiffError("canary lowering fingerprint no longer matches the running code") - for origin, fingerprint in expected_parsers: - if parser_fingerprint_for_origin(origin) != fingerprint: - raise UnclassifiedCanaryDiffError("canary parser fingerprints no longer match the running code") - - -def _validate_parser_binding( - evidence: object, - *, - expected_parser_fingerprints: Iterable[tuple[str, str]], - expected_lowering_fingerprint: str | None, - expected_replay_routing_fingerprint: str | None = None, - expected_materializer_fingerprint: str | None = None, -) -> None: - """Keep parser semantics bound to the selected origin evidence.""" - expected_parsers = dict(expected_parser_fingerprints) - if ( - not expected_parsers - and expected_lowering_fingerprint is None - and expected_replay_routing_fingerprint is None - and expected_materializer_fingerprint is None - ): - return - raw_session_evidence = _raw_session_evidence(evidence) - if raw_session_evidence is None: - raise UnclassifiedCanaryDiffError("canary selection has no parser-fingerprint evidence") - parser_by_origin: dict[str, str] = {} - lowering_values: set[str] = set() - replay_routing_values: set[str] = set() - materializer_values: set[str] = set() - for item in raw_session_evidence: - if not isinstance(item, dict): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is malformed") - origin = item.get("origin") - parser = item.get("parser_fingerprint") - lowering = item.get("lowering_fingerprint") - replay_routing = item.get("replay_routing_fingerprint") - materializer = item.get("materializer_fingerprint") - if not all(isinstance(value, str) and value for value in (origin, parser, lowering)): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is incomplete") - if expected_replay_routing_fingerprint is not None and not isinstance(replay_routing, str): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is incomplete") - if expected_materializer_fingerprint is not None and not isinstance(materializer, str): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is incomplete") - parser_by_origin[str(origin)] = str(parser) - lowering_values.add(str(lowering)) - if expected_replay_routing_fingerprint is not None: - replay_routing_values.add(str(replay_routing)) - if expected_materializer_fingerprint is not None: - materializer_values.add(str(materializer)) - if any(parser_by_origin.get(origin) != parser for origin, parser in expected_parsers.items()): - raise UnclassifiedCanaryDiffError("canary parser fingerprints no longer match the selected origins") - if expected_lowering_fingerprint is not None and lowering_values != {expected_lowering_fingerprint}: - raise UnclassifiedCanaryDiffError("canary lowering fingerprint no longer matches the selected origins") - if expected_replay_routing_fingerprint is not None and replay_routing_values != { - expected_replay_routing_fingerprint - }: - raise UnclassifiedCanaryDiffError("canary replay routing fingerprint no longer matches the selected origins") - if expected_materializer_fingerprint is not None and materializer_values != {expected_materializer_fingerprint}: - raise UnclassifiedCanaryDiffError("canary materializer fingerprint no longer matches the selected origins") - - -def _parser_binding_from_evidence( - evidence: object, -) -> tuple[tuple[tuple[str, str], ...], str | None, str | None, str | None]: - raw_session_evidence = _raw_session_evidence(evidence) - if raw_session_evidence is None: - raise UnclassifiedCanaryDiffError("canary selection has no parser-fingerprint evidence") - parsers: dict[str, str] = {} - lowering: set[str] = set() - replay_routing: set[str] = set() - materializer: set[str] = set() - for item in raw_session_evidence: - if not isinstance(item, dict): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is malformed") - origin = item.get("origin") - parser = item.get("parser_fingerprint") - value = item.get("lowering_fingerprint") - routing = item.get("replay_routing_fingerprint") - materialized = item.get("materializer_fingerprint") - if not all(isinstance(entry, str) and entry for entry in (origin, parser, value, routing, materialized)): - raise UnclassifiedCanaryDiffError("canary parser-fingerprint evidence is incomplete") - parsers[str(origin)] = str(parser) - lowering.add(str(value)) - replay_routing.add(str(routing)) - materializer.add(str(materialized)) - if len(lowering) != 1 or len(replay_routing) != 1 or len(materializer) != 1: - raise UnclassifiedCanaryDiffError("canary selection has inconsistent parser routing fingerprints") - return tuple(sorted(parsers.items())), next(iter(lowering)), next(iter(replay_routing)), next(iter(materializer)) - - -def _raw_session_evidence(evidence: object) -> list[object] | None: - if not isinstance(evidence, dict): - return None - direct = evidence.get("raw_session_evidence") - if isinstance(direct, list): - return direct - closure = evidence.get("replay_closure") - if isinstance(closure, dict) and isinstance(closure.get("raw_session_evidence"), list): - return cast(list[object], closure["raw_session_evidence"]) - return None - - -def _comparison_fingerprint( - comparison: CanaryDiffReport, - *, - schema_version: int = CANARY_REPORT_SCHEMA_VERSION, -) -> str: - """Hash comparison evidence independently of operator classification. - - Version 9 intentionally omitted delta coverage from this payload. Keep that - historical formula available for eligible v9 reports while all new reports - bind the source-version evidence. - """ - - payload: dict[str, object] = { - "current_index": str(comparison.current_index), - "candidate_index": str(comparison.candidate_index), - "session_ids": list(comparison.session_ids), - "compared_tables": list(comparison.compared_tables), - "missing_tables": list(comparison.missing_tables), - "missing_columns": [ - {"table": table, "columns": list(columns)} for table, columns in comparison.missing_columns - ], - "differences": [ - { - "table": difference.table, - "operation": difference.operation.value, - "identity": dict(difference.identity), - "before": difference.before, - "after": difference.after, - "changed_columns": list(difference.changed_columns), - } - for difference in comparison.differences - ], - } - if schema_version >= STRICT_CANARY_REPORT_SCHEMA_VERSION: - payload["source_index_version"] = comparison.source_index_version - payload["undeclared_delta_versions"] = list(comparison.undeclared_delta_versions) - encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8") - return sha256(encoded).hexdigest() - - -def _validate_rebuild_receipt( - receipt: object, - *, - selected_raw_ids: Iterable[str], - selected_session_ids: Iterable[str], - candidate_index: Path | str, - configured_archive_root: Path | None = None, -) -> None: - requested_raw_ids = tuple(selected_raw_ids) - if not isinstance(receipt, dict): - raise UnclassifiedCanaryDiffError("canary report has no rebuild receipt") - archive_root = receipt.get("archive_root") - selected_raw_count = receipt.get("selected_raw_count") - generation = receipt.get("generation") - source_evidence_after = receipt.get("source_evidence_after") - if ( - receipt.get("receipt_schema_version") != 5 - or not isinstance(archive_root, str) - or not archive_root - or not requested_raw_ids - or selected_raw_count != len(requested_raw_ids) - or receipt.get("status") != "replayed" - or receipt.get("materialized") is not True - or not isinstance(generation, dict) - or not isinstance(source_evidence_after, str) - or len(source_evidence_after) != 64 - ): - raise UnclassifiedCanaryDiffError("canary report has invalid rebuild receipt") - generation_archive_root = generation.get("archive_root") - generation_index = generation.get("index_path") - required_generation = ( - generation.get("generation_id"), - generation.get("owner_id"), - generation_archive_root, - generation_index, - generation.get("source_snapshot"), - ) - if ( - generation.get("state") != "inactive" - or not all(isinstance(value, str) and value for value in required_generation) - or not isinstance(generation_archive_root, str) - or not isinstance(generation_index, str) - ): - raise UnclassifiedCanaryDiffError("canary report has incomplete candidate generation provenance") - if Path(archive_root).resolve() != Path(generation_archive_root).resolve(): - raise UnclassifiedCanaryDiffError("canary report rebuild receipt and candidate archive roots disagree") - if ( - configured_archive_root is not None - and Path(cast(str, receipt["archive_root"])).resolve() != configured_archive_root.resolve() - ): - raise UnclassifiedCanaryDiffError( - "canary report rebuild receipt belongs to a different configured archive root" - ) - if Path(generation_index).resolve() != Path(candidate_index).resolve(): - raise UnclassifiedCanaryDiffError("canary report rebuild receipt does not identify the compared candidate") - _validate_selection_evidence( - receipt, - requested_raw_ids, - selected_session_ids=selected_session_ids, - archive_root=configured_archive_root, - ) - - -def _validate_authoritative_rebuild_receipt(receipt: dict[str, object], candidate_index: Path) -> None: - """Reject report receipts that differ from rebuild-owned candidate evidence.""" - - path = candidate_index.parent / "rebuild-receipt.json" - try: - stored = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt is unreadable") from exc - if not isinstance(stored, dict) or stored != receipt: - raise UnclassifiedCanaryDiffError("archive-owned rebuild receipt does not match the report") - - -def _generation_root(location: object) -> Path: - """Return the lifecycle directory anchored by this archive's active pointer.""" - - from polylogue.storage.archive_identity import ArchiveLocation - - assert isinstance(location, ArchiveLocation) - anchor = location.active_pointer or location.configured_tier("index").configured_path - return anchor.parent / ".index-generations" - - -def _read_generation_metadata(generation_root: Path, generation_id: str) -> dict[str, object]: - if not _GENERATION_ID_PATTERN.fullmatch(generation_id): - raise UnclassifiedCanaryDiffError("archive-owned generation id is not a well-formed archive-owned token") - root = generation_root.resolve() - path = root / generation_id / "generation.json" - if path.resolve().parent.parent != root: - raise UnclassifiedCanaryDiffError("archive-owned generation id escapes the generation root") - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise UnclassifiedCanaryDiffError("archive-owned generation metadata is unreadable") from exc - if not isinstance(payload, dict): - raise UnclassifiedCanaryDiffError("archive-owned generation metadata is invalid") - return payload - - -def _generation_fields(metadata: object) -> dict[str, object]: - if not isinstance(metadata, dict): - raise UnclassifiedCanaryDiffError("archive-owned generation metadata is invalid") - fields = { - key: metadata.get(key) - for key in ("generation_id", "owner_id", "archive_root", "index_path", "state", "source_snapshot") - } - if not all(isinstance(value, str) and value for value in fields.values()): - raise UnclassifiedCanaryDiffError("archive-owned generation metadata is incomplete") - return fields - - -def _index_evidence(path: Path) -> dict[str, object]: - """Describe a local file identity without claiming it is a secret capability.""" - - from polylogue.storage.archive_identity import TierFileIdentity - - identity = TierFileIdentity.resolve("index", path) - if not identity.exists: - raise UnclassifiedCanaryDiffError("archive-owned index is not readable") - digest = sha256() - try: - with path.open("rb") as stream: - while chunk := stream.read(1024 * 1024): - digest.update(chunk) - except OSError as exc: - raise UnclassifiedCanaryDiffError("archive-owned index is not readable") from exc - return {"file": identity.as_dict(), "content_sha256": digest.hexdigest()} - - -def _same_index_evidence(recorded: object, path: Path, *, label: str) -> None: - if not isinstance(recorded, dict) or recorded != _index_evidence(path): - raise UnclassifiedCanaryDiffError(f"archive-owned {label} index identity no longer matches the report") - - -def _active_generation_metadata(root: Path, location: object) -> dict[str, object]: - from polylogue.storage.archive_identity import ArchiveLocation, TierFileIdentity - - assert isinstance(location, ArchiveLocation) - active_identity = TierFileIdentity.resolve("index", location.active_index_path) - matches: list[dict[str, object]] = [] - for metadata_path in _generation_root(location).glob("gen-*/generation.json"): - try: - metadata = _read_generation_metadata(_generation_root(location), metadata_path.parent.name) - fields = _generation_fields(metadata) - generation_index = TierFileIdentity.resolve("index", Path(cast(str, fields["index_path"]))) - except (OSError, json.JSONDecodeError): - continue - if fields["state"] == "active" and active_identity.same_file(generation_index): - matches.append(metadata) - if len(matches) != 1: - raise UnclassifiedCanaryDiffError("archive-owned active generation metadata does not match the active pointer") - active = matches[0] - if Path(cast(str, _generation_fields(active)["archive_root"])).resolve() != root.resolve(): - raise UnclassifiedCanaryDiffError("archive-owned active generation belongs to another archive") - return active - - -def _verified_source_evidence(root: Path) -> str: - """Recompute source evidence and normalize byte-verification failures.""" - - from polylogue.storage.index_generation import rebuild_source_evidence_snapshot - - try: - return rebuild_source_evidence_snapshot(root) - except (OSError, RuntimeError, ValueError) as exc: - raise UnclassifiedCanaryDiffError( - f"archive-owned source evidence cannot verify referenced blob bytes: {exc}" - ) from exc - - -def _capture_archive_provenance(comparison: CanaryDiffReport, receipt: dict[str, object]) -> dict[str, object]: - """Capture lifecycle records that make a local report archive-specific. - - These values are not a secret or a signature. They are deliberately - re-read on load so approval follows the active pointer, generation record, - source snapshot, and the exact inactive file that the rebuild created. - """ - - from polylogue.storage.archive_identity import ArchiveLocation, TierFileIdentity - - archive_root = receipt.get("archive_root") - generation = receipt.get("generation") - if not isinstance(archive_root, str): - raise UnclassifiedCanaryDiffError("canary report has invalid archive provenance root") - root = Path(archive_root) - location = ArchiveLocation.resolve(root) - current_identity = TierFileIdentity.resolve("index", comparison.current_index) - if not location.active_index.same_file(current_identity): - raise UnclassifiedCanaryDiffError("archive-owned active index does not match the canary comparison") - if not isinstance(generation, dict): - raise UnclassifiedCanaryDiffError("canary report has invalid candidate generation provenance") - candidate = _generation_fields(generation) - candidate_metadata = _read_generation_metadata(_generation_root(location), cast(str, candidate["generation_id"])) - if candidate_metadata != generation: - raise UnclassifiedCanaryDiffError( - "archive-owned candidate generation metadata does not match the rebuild receipt" - ) - if candidate["state"] != "inactive": - raise UnclassifiedCanaryDiffError("archive-owned candidate generation is not inactive") - candidate_path = Path(cast(str, candidate["index_path"])) - if not candidate_path.samefile(comparison.candidate_index): - raise UnclassifiedCanaryDiffError("archive-owned candidate generation does not match the canary comparison") - source_snapshot = _verified_source_evidence(root) - if source_snapshot != candidate["source_snapshot"]: - raise UnclassifiedCanaryDiffError("archive-owned source snapshot does not match the inactive candidate") - source_evidence_after = _verified_source_evidence(root) - if source_evidence_after != receipt.get("source_evidence_after"): - raise UnclassifiedCanaryDiffError("archive-owned source evidence does not match the rebuild receipt") - parser_fingerprints, lowering_fingerprint, replay_routing_fingerprint, materializer_fingerprint = ( - _parser_binding_from_evidence(receipt.get("selection_evidence")) - ) - return { - "archive_root": str(root.resolve()), - "active_pointer": str(location.active_pointer) if location.active_pointer is not None else None, - "active_index": _index_evidence(location.active_index_path), - "active_generation": _active_generation_metadata(root, location), - "candidate_generation": candidate_metadata, - "candidate_index": _index_evidence(candidate_path), - "source_snapshot": source_snapshot, - "source_evidence_after": source_evidence_after, - "parser_fingerprints": dict(parser_fingerprints), - "lowering_fingerprint": lowering_fingerprint, - "replay_routing_fingerprint": replay_routing_fingerprint, - "materializer_fingerprint": materializer_fingerprint, - } - - -def _validate_archive_provenance( - provenance: object, - *, - configured_archive_root: Path, - current_index: Path, - candidate_index: Path, - receipt: dict[str, object], -) -> None: - """Validate archive-owned evidence before opening report-provided indexes.""" - - from polylogue.storage.archive_identity import ArchiveLocation, TierFileIdentity - - if not isinstance(provenance, dict): - raise UnclassifiedCanaryDiffError("canary report has no archive-owned provenance") - archive_root = provenance.get("archive_root") - if not isinstance(archive_root, str): - raise UnclassifiedCanaryDiffError("canary report has invalid archive-owned provenance root") - root = configured_archive_root.resolve() - if Path(archive_root).resolve() != root: - raise UnclassifiedCanaryDiffError("canary report belongs to a different configured archive root") - receipt_root = receipt.get("archive_root") - if not isinstance(receipt_root, str) or Path(receipt_root).resolve() != root: - raise UnclassifiedCanaryDiffError("archive-owned provenance root does not match the rebuild receipt") - location = ArchiveLocation.resolve(root) - pointer = str(location.active_pointer) if location.active_pointer is not None else None - if provenance.get("active_pointer") != pointer: - raise UnclassifiedCanaryDiffError("archive-owned active pointer no longer matches the report") - if provenance.get("active_generation") != _active_generation_metadata(root, location): - raise UnclassifiedCanaryDiffError("archive-owned active generation metadata no longer matches the report") - current_identity = TierFileIdentity.resolve("index", current_index) - if not location.active_index.same_file(current_identity): - raise UnclassifiedCanaryDiffError("archive-owned active index no longer matches the report") - _same_index_evidence(provenance.get("active_index"), location.active_index_path, label="active") - receipt_generation = receipt.get("generation") - if not isinstance(receipt_generation, dict): - raise UnclassifiedCanaryDiffError("canary report has invalid candidate generation provenance") - receipt_fields = _generation_fields(receipt_generation) - if provenance.get("candidate_generation") != receipt_generation: - raise UnclassifiedCanaryDiffError("archive-owned candidate generation does not match the rebuild receipt") - live_candidate = _read_generation_metadata(_generation_root(location), cast(str, receipt_fields["generation_id"])) - live_fields = _generation_fields(live_candidate) - if live_candidate != receipt_generation or live_fields["state"] != "inactive": - raise UnclassifiedCanaryDiffError("archive-owned candidate generation metadata no longer matches the report") - candidate_path = Path(cast(str, live_fields["index_path"])) - generation_root = _generation_root(location).resolve() - candidate_resolved = candidate_path.resolve(strict=True) - expected_generation_path = generation_root / str(receipt_fields["generation_id"]) / "index.db" - if candidate_resolved != expected_generation_path.resolve(): - raise UnclassifiedCanaryDiffError("archive-owned candidate generation path is not archive-owned") - try: - same_candidate = candidate_path.samefile(candidate_index) - except OSError as exc: - raise UnclassifiedCanaryDiffError("archive-owned candidate generation is not readable") from exc - if not same_candidate: - raise UnclassifiedCanaryDiffError("archive-owned candidate generation no longer matches the report") - _same_index_evidence(provenance.get("candidate_index"), candidate_path, label="candidate") - parser_fingerprints, lowering_fingerprint, replay_routing_fingerprint, materializer_fingerprint = ( - _parser_binding_from_evidence(receipt.get("selection_evidence")) - ) - if provenance.get("parser_fingerprints") != dict(parser_fingerprints): - raise UnclassifiedCanaryDiffError("archive-owned parser fingerprints no longer match the report") - if provenance.get("lowering_fingerprint") != lowering_fingerprint: - raise UnclassifiedCanaryDiffError("archive-owned lowering fingerprint no longer matches the report") - if provenance.get("replay_routing_fingerprint") != replay_routing_fingerprint: - raise UnclassifiedCanaryDiffError("archive-owned replay routing fingerprint no longer matches the report") - if provenance.get("materializer_fingerprint") != materializer_fingerprint: - raise UnclassifiedCanaryDiffError("archive-owned materializer fingerprint no longer matches the report") - source_snapshot = _verified_source_evidence(root) - if provenance.get("source_snapshot") != source_snapshot or live_fields["source_snapshot"] != source_snapshot: - raise UnclassifiedCanaryDiffError("archive-owned source snapshot no longer matches the inactive candidate") - source_evidence_after = _verified_source_evidence(root) - if ( - provenance.get("source_evidence_after") != source_evidence_after - or receipt.get("source_evidence_after") != source_evidence_after - ): - raise UnclassifiedCanaryDiffError("archive-owned source evidence no longer matches the rebuild receipt") - - -def _validate_report_archive_root(payload: dict[str, object], *, configured_archive_root: Path) -> Path: - """Bind report and receipt roots before reading any archive evidence.""" - - root = configured_archive_root.resolve() - receipt = payload.get("rebuild_receipt") - if not isinstance(receipt, dict): - raise UnclassifiedCanaryDiffError("canary report has no rebuild receipt") - receipt_root = receipt.get("archive_root") - if not isinstance(receipt_root, str) or not receipt_root: - raise UnclassifiedCanaryDiffError("canary report has invalid rebuild receipt archive root") - provenance = payload.get("archive_provenance") - if Path(receipt_root).resolve() != root: - raise UnclassifiedCanaryDiffError( - "canary report rebuild receipt belongs to a different configured archive root" - ) - if isinstance(provenance, dict): - report_root = provenance.get("archive_root") - if isinstance(report_root, str) and report_root and Path(report_root).resolve() != root: - raise UnclassifiedCanaryDiffError("canary report belongs to a different configured archive root") - return root - - -def load_canary_report(path: Path, *, archive_root: Path | None = None) -> dict[str, object]: - """Read and structurally revalidate a durable report's review coverage.""" - - payload = json.loads(Path(path).read_text(encoding="utf-8")) - if not isinstance(payload, dict): - raise UnclassifiedCanaryDiffError("canary report root must be an object") - report_schema_version = payload.get("schema_version") - if report_schema_version not in ( - LEGACY_CANARY_REPORT_SCHEMA_VERSION, - STRICT_CANARY_REPORT_SCHEMA_VERSION, - CANARY_REPORT_SCHEMA_VERSION, - ): - raise UnclassifiedCanaryDiffError("canary report has no authoritative rebuild receipt schema") - assert isinstance(report_schema_version, int) - configured_root = ( - _validate_report_archive_root(payload, configured_archive_root=Path(archive_root)) - if archive_root is not None - else None - ) - comparison = payload.get("comparison") - if not isinstance(comparison, dict): - raise UnclassifiedCanaryDiffError("canary report has no comparison object") - summary = comparison.get("summary") - if not isinstance(summary, dict): - raise UnclassifiedCanaryDiffError("canary report has no comparison summary object") - raw_differences = comparison.get("differences") - if not isinstance(raw_differences, list): - raise UnclassifiedCanaryDiffError("canary report has no differences list") - differences = tuple(_difference_from_dict(item) for item in raw_differences) - raw_reviews = payload.get("reviews") - if not isinstance(raw_reviews, list): - raise UnclassifiedCanaryDiffError("canary report has no reviews list") - reviews = tuple(_review_from_dict(item) for item in raw_reviews) - review_status = payload.get("review_status") - if review_status != "reviewed": - raise UnclassifiedCanaryDiffError("canary report is not fully reviewed") - comparison_fingerprint = payload.get("comparison_fingerprint") - if not isinstance(comparison_fingerprint, str) or len(comparison_fingerprint) != 64: - raise UnclassifiedCanaryDiffError("canary report has invalid comparison fingerprint") - selection = payload.get("selection") - if not isinstance(selection, dict): - raise UnclassifiedCanaryDiffError("canary report has no selection object") - selected_raw_ids = selection.get("selected_raw_ids") - selection_sessions = selection.get("selected_session_ids") - candidate_index = comparison.get("candidate_index") - if ( - not isinstance(selected_raw_ids, list) - or not selected_raw_ids - or not all(isinstance(value, str) and value for value in selected_raw_ids) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid selected raw ids") - if ( - not isinstance(selection_sessions, list) - or not selection_sessions - or not all(isinstance(value, str) and value for value in selection_sessions) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid selected session ids") - if not isinstance(candidate_index, str): - raise UnclassifiedCanaryDiffError("canary report has no candidate index") - _validate_rebuild_receipt( - payload.get("rebuild_receipt"), - selected_raw_ids=cast(list[str], selected_raw_ids), - selected_session_ids=cast(list[str], selection_sessions), - candidate_index=candidate_index, - configured_archive_root=configured_root, - ) - selection_index = selection.get("index_path") - sessions_per_origin = selection.get("sessions_per_origin") - raw_parser_fingerprints = selection.get("parser_fingerprints") - lowering_fingerprint = selection.get("lowering_fingerprint") - replay_routing_fingerprint = selection.get("replay_routing_fingerprint") - materializer_fingerprint = selection.get("materializer_fingerprint") - if ( - not isinstance(selection_index, str) - or not isinstance(sessions_per_origin, int) - or not isinstance(raw_parser_fingerprints, dict) - or not all( - isinstance(origin, str) and isinstance(fingerprint, str) - for origin, fingerprint in raw_parser_fingerprints.items() - ) - or (lowering_fingerprint is not None and not isinstance(lowering_fingerprint, str)) - or (replay_routing_fingerprint is not None and not isinstance(replay_routing_fingerprint, str)) - or (materializer_fingerprint is not None and not isinstance(materializer_fingerprint, str)) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid selection binding") - persisted_selection = CanarySelection( - index_path=Path(selection_index), - sessions_per_origin=sessions_per_origin, - selected_session_ids=tuple(cast(list[str], selection_sessions)), - selected_raw_ids=tuple(cast(list[str], selected_raw_ids)), - sampled_session_ids=(), - pathology_session_ids=(), - sample_session_ids=(), - origin_counts=(), - parser_fingerprints=tuple(sorted(cast(dict[str, str], raw_parser_fingerprints).items())), - lowering_fingerprint=lowering_fingerprint, - replay_routing_fingerprint=replay_routing_fingerprint, - materializer_fingerprint=materializer_fingerprint, - ) - current_index = comparison.get("current_index") - compared_sessions = comparison.get("session_ids") - compared_tables = comparison.get("compared_tables") - missing_tables = comparison.get("missing_tables") - raw_missing_columns = comparison.get("missing_columns") - if ( - not isinstance(current_index, str) - or not isinstance(compared_sessions, list) - or not all(isinstance(value, str) for value in compared_sessions) - or not isinstance(compared_tables, list) - or not all(isinstance(value, str) for value in compared_tables) - or not isinstance(missing_tables, list) - or not all(isinstance(value, str) for value in missing_tables) - or not isinstance(raw_missing_columns, list) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid comparison selection") - raw_delta_coverage = comparison.get("delta_coverage") - if not isinstance(raw_delta_coverage, dict): - if report_schema_version == LEGACY_CANARY_REPORT_SCHEMA_VERSION: - raise UnclassifiedCanaryDiffError( - "legacy canary report lacks source index version evidence; cannot validate its fingerprint" - ) - raise UnclassifiedCanaryDiffError("canary report has no delta coverage object") - source_index_version = raw_delta_coverage.get("source_index_version") - undeclared_delta_versions = raw_delta_coverage.get("undeclared_delta_versions") - if source_index_version is not None and ( - not isinstance(source_index_version, int) or isinstance(source_index_version, bool) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid source index version") - if report_schema_version == LEGACY_CANARY_REPORT_SCHEMA_VERSION and source_index_version is None: - raise UnclassifiedCanaryDiffError( - "legacy canary report lacks source index version evidence; cannot validate its fingerprint or delta authority" - ) - if not isinstance(undeclared_delta_versions, list) or not all( - isinstance(version, int) and not isinstance(version, bool) for version in undeclared_delta_versions - ): - raise UnclassifiedCanaryDiffError("canary report has invalid undeclared delta versions") - missing_columns: list[tuple[str, tuple[str, ...]]] = [] - for item in raw_missing_columns: - if not isinstance(item, dict): - raise UnclassifiedCanaryDiffError("canary report has invalid comparison schema evidence") - table = item.get("table") - columns = item.get("columns") - if ( - not isinstance(table, str) - or not isinstance(columns, list) - or not all(isinstance(column, str) for column in columns) - ): - raise UnclassifiedCanaryDiffError("canary report has invalid comparison schema evidence") - missing_columns.append((table, tuple(columns))) - persisted_comparison = CanaryDiffReport( - current_index=Path(current_index), - candidate_index=Path(candidate_index), - session_ids=tuple(cast(list[str], compared_sessions)), - compared_tables=tuple(cast(list[str], compared_tables)), - missing_tables=tuple(cast(list[str], missing_tables)), - missing_columns=tuple(missing_columns), - differences=differences, - source_index_version=source_index_version, - undeclared_delta_versions=tuple(cast(list[int], undeclared_delta_versions)), - ) - from polylogue.config import resolve_archive_root - - authority_root = configured_root if configured_root is not None else resolve_archive_root() - receipt = cast(dict[str, object], payload["rebuild_receipt"]) - sealed_attestation: CanaryComparisonAttestation | None = None - if report_schema_version == CANARY_REPORT_SCHEMA_VERSION: - sealed_attestation = _validate_sealed_comparison_attestation( - payload.get("comparison_attestation"), - comparison=persisted_comparison, - selection=persisted_selection, - receipt=receipt, - configured_archive_root=authority_root, - ) - sealed_crossed = sealed_attestation.payload.get("crossed_delta_versions") - if not isinstance(sealed_crossed, list) or not all(isinstance(value, int) for value in sealed_crossed): - raise UnclassifiedCanaryDiffError("canary comparison attestation has invalid crossed delta evidence") - crossed_delta_versions: frozenset[int] | None = frozenset(cast(list[int], sealed_crossed)) - else: - # v9/v10 have no archive-owned historical baseline. Retain their strict - # current-active comparison and authority behavior indefinitely. - crossed_delta_versions = _crossed_delta_versions_for_comparison(persisted_comparison) - _validate_expected_review_authorities( - reviews, - crossed_delta_versions=crossed_delta_versions, - require_crossed_delta_versions=True, - ) - _validate_selection_binding( - persisted_selection, - persisted_comparison, - receipt, - archive_root=configured_root, - ) - if report_schema_version == CANARY_REPORT_SCHEMA_VERSION: - if comparison_fingerprint != _comparison_fingerprint(persisted_comparison): - raise UnclassifiedCanaryDiffError( - "canary report comparison fingerprint does not match its sealed comparison" - ) - else: - _validate_archive_provenance( - payload.get("archive_provenance"), - configured_archive_root=authority_root, - current_index=persisted_comparison.current_index, - candidate_index=persisted_comparison.candidate_index, - receipt=receipt, - ) - _validate_authoritative_rebuild_receipt(receipt, persisted_comparison.candidate_index) - recomputed_comparison = compare_reindex_generations( - persisted_comparison.current_index, - persisted_comparison.candidate_index, - session_ids=persisted_selection.selected_session_ids, - source_index_version=persisted_comparison.source_index_version, - undeclared_delta_versions=persisted_comparison.undeclared_delta_versions, - ) - if comparison_fingerprint != _comparison_fingerprint( - persisted_comparison, schema_version=report_schema_version - ) or comparison_fingerprint != _comparison_fingerprint( - recomputed_comparison, schema_version=report_schema_version - ): - raise UnclassifiedCanaryDiffError( - "canary report comparison attestation does not match the recorded indexes" - ) - difference_keys = tuple(_difference_key(difference) for difference in differences) - review_keys = tuple(review.key for review in reviews) - if len(set(difference_keys)) != len(difference_keys) or len(set(review_keys)) != len(review_keys): - raise UnclassifiedCanaryDiffError("canary report contains duplicate difference or review identities") - difference_by_key = dict(zip(difference_keys, differences, strict=True)) - review_by_key = dict(zip(review_keys, reviews, strict=True)) - missing_keys = set(difference_by_key).difference(review_by_key) - extra_keys = set(review_by_key).difference(difference_by_key) - if missing_keys or extra_keys: - raise UnclassifiedCanaryDiffError( - f"canary report review coverage is incomplete (missing={len(missing_keys)}, extra={len(extra_keys)})" - ) - for key, difference in difference_by_key.items(): - review = review_by_key[key] - if review.classification is not difference.classification: - raise UnclassifiedCanaryDiffError("canary report review classification disagrees with its difference") - if difference.rationale != _reviewed_difference_rationale(review): - raise UnclassifiedCanaryDiffError( - "canary report difference rationale disagrees with its structured authority" - ) - comparison["summary"] = _summary_for_differences(differences) - return cast(dict[str, object], payload) - - -def approve_canary_report(path: Path, *, archive_root: Path) -> dict[str, object]: - """Approve evidence only under the archive's existing writer ownership.""" - - from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation, assert_owns_archive_location - from polylogue.storage.index_generation import RebuildLease - - root = Path(archive_root) - location = ArchiveLocation.resolve(root) - with RebuildLease(root): - owned = OwnedArchiveLocation.acquire(location) - try: - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - _validate_approved_canary_report(path, root) - - # The first load proves that the report was valid when approval - # began. Re-run the complete report, source, candidate, and - # comparison validation immediately before returning approval. - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - final_payload = _validate_approved_canary_report(path, root) - assert_owns_archive_location(owned, ArchiveLocation.resolve(root)) - return final_payload - finally: - owned.release() - - -def approve_canary_report_under_daemon_ownership(path: Path, *, archive_root: Path) -> dict[str, object]: - """Validate report approval while the daemon write coordinator owns the archive.""" - - from polylogue.daemon.write_coordinator import daemon_write_lease_active - - if not daemon_write_lease_active(): - raise RuntimeError("canary report consumption requires the daemon write coordinator") - root = Path(archive_root) - _validate_approved_canary_report(path, root) - return _validate_approved_canary_report(path, root) - - -def _validate_approved_canary_report(path: Path, archive_root: Path) -> dict[str, object]: - payload = load_canary_report(path, archive_root=archive_root) - if payload.get("review_status") != "reviewed": - raise UnclassifiedCanaryDiffError("canary report is not approved: review is incomplete") - comparison = payload.get("comparison") - summary = comparison.get("summary") if isinstance(comparison, dict) else None - if not isinstance(summary, dict) or summary.get("unexpected_count") != 0: - raise UnclassifiedCanaryDiffError("canary report is not approved: unexpected differences remain") - return payload - - -def _difference_key( - difference: RowDifference, -) -> tuple[str, DifferenceOperation, tuple[tuple[str, object], ...], tuple[str, ...]]: - return difference.table, difference.operation, _canonical_identity(difference.identity), difference.changed_columns - - -def _difference_from_dict(value: object) -> RowDifference: - if not isinstance(value, dict): - raise UnclassifiedCanaryDiffError("canary report difference must be an object") - identity = value.get("identity") - changed_columns = value.get("changed_columns") - if ( - not isinstance(identity, dict) - or not isinstance(changed_columns, list) - or not all(isinstance(column, str) for column in changed_columns) - ): - raise UnclassifiedCanaryDiffError("canary report difference has invalid identity or changed columns") - before = value.get("before") - after = value.get("after") - if before is not None and not isinstance(before, dict): - raise UnclassifiedCanaryDiffError("canary report difference has invalid before row") - if after is not None and not isinstance(after, dict): - raise UnclassifiedCanaryDiffError("canary report difference has invalid after row") - table = value.get("table") - rationale = value.get("rationale") - if not isinstance(table, str) or not isinstance(rationale, str): - raise UnclassifiedCanaryDiffError("canary report difference has invalid table or rationale") - try: - operation = DifferenceOperation(value["operation"]) - classification = DifferenceClassification(value["classification"]) - except (KeyError, ValueError) as exc: - raise UnclassifiedCanaryDiffError("canary report difference has invalid operation or classification") from exc - return RowDifference( - table=table, - operation=operation, - identity=tuple((str(key), item) for key, item in identity.items()), - before=cast(dict[str, object] | None, before), - after=cast(dict[str, object] | None, after), - changed_columns=tuple(cast(list[str], changed_columns)), - classification=classification, - rationale=rationale, - ) - - -def _review_from_dict(value: object) -> CanaryDifferenceReview: - if not isinstance(value, dict): - raise UnclassifiedCanaryDiffError("canary report review must be an object") - identity = value.get("identity") - changed_columns = value.get("changed_columns") - table = value.get("table") - reference = value.get("reference") - authority = value.get("authority") - rationale = value.get("rationale") - if ( - not isinstance(identity, dict) - or not isinstance(changed_columns, list) - or not changed_columns - or not all(isinstance(column, str) for column in changed_columns) - or not isinstance(table, str) - or not isinstance(reference, str) - or not isinstance(authority, dict) - or not isinstance(authority.get("kind"), str) - or not isinstance(authority.get("id"), str) - or not isinstance(rationale, str) - or not reference.strip() - or not rationale.strip() - ): - raise UnclassifiedCanaryDiffError("every canary review needs a valid reference and rationale") - try: - operation = DifferenceOperation(value["operation"]) - classification = DifferenceClassification(value["classification"]) - except (KeyError, ValueError) as exc: - raise UnclassifiedCanaryDiffError("canary report review has invalid operation or classification") from exc - try: - authority_kind = CanaryAuthorityKind(authority["kind"]) - except (KeyError, ValueError) as exc: - raise UnclassifiedCanaryDiffError("canary report review has invalid structured authority") from exc - return CanaryDifferenceReview( - table=table, - operation=operation, - identity=tuple((str(key), item) for key, item in identity.items()), - changed_columns=tuple(cast(list[str], changed_columns)), - classification=classification, - reference=reference, - rationale=rationale, - authority_kind=authority_kind, - authority_id=authority["id"], - ) - - -def load_canary_review_manifest(path: Path) -> tuple[CanaryDifferenceReview, ...]: - """Load the explicit per-difference review manifest accepted by the CLI.""" - - payload = json.loads(Path(path).read_text(encoding="utf-8")) - if not isinstance(payload, dict) or not isinstance(payload.get("reviews"), list): - raise UnclassifiedCanaryDiffError("canary review manifest must contain a reviews list") - reviews = tuple(_review_from_dict(item) for item in cast(list[object], payload["reviews"])) - _validate_expected_review_authorities(reviews) - return reviews - - -def _summary_for_differences(differences: tuple[RowDifference, ...]) -> dict[str, object]: - expected_count = sum(item.classification is DifferenceClassification.EXPECTED for item in differences) - unexpected_count = sum(item.classification is DifferenceClassification.UNEXPECTED for item in differences) - return { - "difference_count": len(differences), - "expected_count": expected_count, - "unexpected_count": unexpected_count, - "unclassified_count": 0, - "counts_by_table": dict(sorted(Counter(item.table for item in differences).items())), - } - - -def _reviewed_difference_rationale(review: CanaryDifferenceReview) -> str: - """Return the only persisted rationale text a structured review may authorize.""" - return f"{review.reference}: {review.rationale}" - - -def _session_id_from_identity(identity: dict[str, object]) -> str | None: - """Resolve a row identity to a session_id, composing it when necessary. - - ``sessions.session_id`` is a generated column (``origin || ':' || native_id``), - so a row identity taken from the table's natural key carries ``origin`` and - ``native_id`` and no ``session_id`` at all. Requiring the generated spelling - literally made every ``sessions`` difference unclassifiable against a - reprocess-scoped delta -- including the v44 title_ref/title_confidence case - that scope exists for, whose canary identity is exactly - ``{"native_id": ..., "origin": "codex-session"}``. Compose it from the same - definition the schema uses rather than teaching callers a second spelling. - """ - session_id = identity.get("session_id") - if isinstance(session_id, str): - return session_id - origin = identity.get("origin") - native_id = identity.get("native_id") - if isinstance(origin, str) and isinstance(native_id, str): - return f"{origin}:{native_id}" - return None - - -def _validate_expected_review_authorities( - reviews: Iterable[CanaryDifferenceReview], - *, - crossed_delta_versions: Iterable[int] | None = None, - require_crossed_delta_versions: bool = False, -) -> None: - """Resolve approval-capable authorities from crossed product declarations. - - An expected semantic difference can approve a canary, so it must name an - executable index delta shipped in the same package *and crossed by the - compared generations*. A Bead is planning state, not semantic evidence. - Unexpected differences may name a successor for human follow-up, but - approval rejects them regardless of that label. - """ - from polylogue.storage.sqlite.lifecycle import INDEX_DELTA_DECLARATIONS - - crossed = None if crossed_delta_versions is None else frozenset(crossed_delta_versions) - crossed_detail = "" if crossed is None else f" (crossed versions: {sorted(crossed)})" - - expected_reviews = tuple( - review - for review in reviews - if review.classification is DifferenceClassification.EXPECTED - and review.authority_kind is CanaryAuthorityKind.DELTA - and review.authority_id is not None - ) - if expected_reviews and require_crossed_delta_versions and crossed is None: - raise UnclassifiedCanaryDiffError( - "expected delta authority requires source index version evidence and a crossed declaration set" - ) - declarations_by_id = {str(declaration.version): declaration for declaration in INDEX_DELTA_DECLARATIONS} - unknown_deltas = sorted( - { - authority_id - for review in expected_reviews - if (authority_id := review.authority_id) is not None and authority_id not in declarations_by_id - } - ) - if unknown_deltas: - detail = ", ".join(f"unknown index delta {delta}" for delta in unknown_deltas) - raise UnclassifiedCanaryDiffError(f"expected canary authority is not declared in packaged evidence: {detail}") - unrelated_reviews: list[str] = [] - for review in expected_reviews: - authority_id = review.authority_id - assert authority_id is not None - declaration = declarations_by_id[authority_id] - if crossed is not None and declaration.version not in crossed: - unrelated_reviews.append(f"delta {authority_id} is not crossed by this comparison{crossed_detail}") - continue - if not (declaration.requires_semantic_reparse or declaration.requires_targeted_reprocess): - unrelated_reviews.append(f"delta {authority_id} does not declare a semantic reparse") - continue - # A SEMANTIC_REPARSE declaration ships no fast-forward SQL by design -- - # it routes to a full rebuild instead -- so its comparable table scope - # cannot come from ``operations``. Reading it from the declaration's - # own ``expected_canary_changes`` is what lets a semantic delta author - # an expected difference at all; sourcing it only from ``operations`` - # made every semantic delta structurally unable to approve the very - # rows a reindex exists to change. - declared_tables = { - object_name - for operation in declaration.operations - for object_kind, object_name in operation.objects - if object_kind == "table" - } | {change.table for change in declaration.expected_canary_changes} - if not declared_tables: - unrelated_reviews.append(f"delta {authority_id} does not declare comparable table scope") - elif review.table not in declared_tables: - unrelated_reviews.append(f"delta {authority_id} does not declare table {review.table}") - continue - else: - review_is_schema = dict(review.identity).get("__schema__") in {"table", "column"} - matching_changes = tuple( - change - for change in declaration.expected_canary_changes - if ( - change.table == review.table - and review.operation.value in change.operations - and (change.scope == "schema") == review_is_schema - ) - ) - if not matching_changes: - unrelated_reviews.append( - f"delta {authority_id} does not declare {review.operation.value} canary changes for " - f"table {review.table}" - ) - continue - if not any(set(review.changed_columns) <= set(change.columns) for change in matching_changes): - unrelated_reviews.append( - f"delta {authority_id} does not declare changed columns {review.changed_columns!r} " - f"for table {review.table}" - ) - continue - # Schema differences have no session row identity. Their authority - # is the declared DDL signature itself, never the row-level - # reprocess scope carried by a targeted semantic delta. - if review_is_schema: - continue - scope = declaration.reprocess_scope - if scope is not None: - identity = dict(review.identity) - session_id = _session_id_from_identity(identity) - if not isinstance(session_id, str): - unrelated_reviews.append(f"delta {authority_id} requires a session_id row identity") - elif scope.origin is not None and not session_id.startswith(f"{scope.origin}:"): - unrelated_reviews.append( - f"delta {authority_id} does not declare session {session_id} outside origin {scope.origin}" - ) - elif scope.session_ids and session_id not in scope.session_ids: - unrelated_reviews.append(f"delta {authority_id} does not declare session {session_id}") - if unrelated_reviews: - raise UnclassifiedCanaryDiffError( - "expected canary authority does not cover the reviewed difference: " + "; ".join(unrelated_reviews) - ) - - -def _fsync_directory(directory: Path) -> None: - descriptor = os.open(str(directory), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - - -# These are SQLite implementation details rather than semantic read models. -# FTS backing tables are virtual-table internals and generation metadata is not -# part of the session read model. Aggregate rollups without a session key -# cannot be attributed to a partial canary and are therefore intentionally -# outside this comparator's scope. -_EXCLUDED_TABLES = frozenset( - { - "delegation_refresh_scope", - "derived_refresh_guard", - "agent_meta_sidecar_purge_receipts", - "session_tag_rollups", - } -) -_CORE_TABLES = ("sessions", "messages", "blocks", "session_links") -_SESSION_SCOPE_COLUMNS = ( - "session_id", - "src_session_id", - "resolved_dst_session_id", - "parent_session_id", - "child_session_id", -) -_VOLATILE_COLUMNS = frozenset( - { - "generation_id", - "generation_owner_id", - "materialized_at", - "materialized_at_ms", - "materialized_at_utc", - "refreshed_at_ms", - } -) -_VOLATILE_COLUMNS_BY_TABLE = { - # Rebuild attempts mint a fresh receipt id and timestamp. Their semantic - # decision tuple remains compared below under the table's unique logical - # identity, so accepted/superseded/rejected authority drift stays visible. - "raw_revision_applications": frozenset({"decision_id", "decided_at_ms"}), - "raw_revision_heads": frozenset({"decided_at_ms"}), -} - - -def compare_reindex_generations( - current_index: Path, - candidate_index: Path, - *, - session_ids: Iterable[str] = (), - expected: Iterable[ExpectedDifference] = (), - delta_expectations: Iterable[DeltaExpectation] = (), - source_index_version: int | None = None, - undeclared_delta_versions: Iterable[int] = (), -) -> CanaryDiffReport: - """Compare real generation read models without mutating either database. - - ``current_index`` is the active generation and ``candidate_index`` is the - inactive generation produced by the existing rebuild service. When no - session selection is supplied, the union of session ids in both databases - is compared. A partial selection is useful for the N-per-origin canary - and still reports additions/removals inside that selection. - """ - - current_path = Path(current_index) - candidate_path = Path(candidate_index) - if not current_path.exists(): - raise FileNotFoundError(f"current index does not exist: {current_path}") - if not candidate_path.exists(): - raise FileNotFoundError(f"candidate index does not exist: {candidate_path}") - - reviewed = tuple(expected) - declared = tuple(delta_expectations) - with _open_read_only(current_path) as current, _open_read_only(candidate_path) as candidate: - current_tables = _read_model_tables(current) - candidate_tables = _read_model_tables(candidate) - compared_tables = tuple(sorted(current_tables.intersection(candidate_tables))) - missing_tables = tuple(sorted(current_tables.symmetric_difference(candidate_tables))) - schema_differences: list[RowDifference] = [] - missing_columns: list[tuple[str, tuple[str, ...]]] = [] - for table in missing_tables: - current_present = table in current_tables - current_columns = _table_columns(current, table) if current_present else () - candidate_columns = _table_columns(candidate, table) if table in candidate_tables else () - operation = DifferenceOperation.REMOVED if current_present else DifferenceOperation.ADDED - changed_columns = tuple(sorted(set(current_columns).union(candidate_columns))) - before: dict[str, object] | None = ( - {"table": table, "columns": list(current_columns)} if current_present else None - ) - after: dict[str, object] | None = ( - {"table": table, "columns": list(candidate_columns)} if table in candidate_tables else None - ) - schema_differences.append( - _build_difference( - table=table, - operation=operation, - identity=(("__schema__", "table"),), - before=before, - after=after, - changed_columns=changed_columns, - expected=reviewed, - delta_expectations=declared, - ) - ) - for table in compared_tables: - current_column_set = set(_table_columns(current, table)) - candidate_column_set = set(_table_columns(candidate, table)) - only_current = current_column_set - candidate_column_set - only_candidate = candidate_column_set - current_column_set - if only_current or only_candidate: - missing_columns.append((table, tuple(sorted(only_current.union(only_candidate))))) - for column in sorted(only_current): - schema_differences.append( - _build_difference( - table=table, - operation=DifferenceOperation.REMOVED, - identity=(("__schema__", "column"), ("name", column)), - before={"table": table, "column": column}, - after=None, - changed_columns=(column,), - expected=reviewed, - delta_expectations=declared, - ) - ) - for column in sorted(only_candidate): - schema_differences.append( - _build_difference( - table=table, - operation=DifferenceOperation.ADDED, - identity=(("__schema__", "column"), ("name", column)), - before=None, - after={"table": table, "column": column}, - changed_columns=(column,), - expected=reviewed, - delta_expectations=declared, - ) - ) - selected_sessions = _selected_session_ids(current, candidate, session_ids) - differences = schema_differences - for table in compared_tables: - differences.extend( - _compare_table( - table, - current, - candidate, - session_ids=selected_sessions, - expected=reviewed, - delta_expectations=declared, - ) - ) - - return CanaryDiffReport( - current_index=current_path, - candidate_index=candidate_path, - session_ids=selected_sessions, - compared_tables=compared_tables, - missing_tables=missing_tables, - missing_columns=tuple(missing_columns), - differences=tuple(differences), - source_index_version=source_index_version, - undeclared_delta_versions=tuple(undeclared_delta_versions), - ) - - -def _open_read_only(path: Path) -> sqlite3.Connection: - uri = f"file:{path.resolve(strict=True)}?mode=ro" - connection = sqlite3.connect(uri, uri=True) - connection.row_factory = sqlite3.Row - connection.execute("PRAGMA query_only = ON") - return connection - - -def _read_model_tables(connection: sqlite3.Connection) -> set[str]: - rows = connection.execute( - "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'" - ).fetchall() - result: set[str] = set() - for row in rows: - table = str(row[0]) - if table in _EXCLUDED_TABLES or table.startswith("messages_fts") or table.startswith("blocks_command_trigram"): - continue - try: - columns = _table_columns(connection, table) - except sqlite3.OperationalError: - # A canonical view whose backing table is itself absent is not a - # second independent schema delta. The missing base relation is - # still reported below without making the comparison unreadable. - continue - if table in _CORE_TABLES or any(column in columns for column in _SESSION_SCOPE_COLUMNS): - result.add(table) - return result - - -def _table_columns(connection: sqlite3.Connection, table: str) -> tuple[str, ...]: - quoted = _quote_identifier(table) - rows = connection.execute(f"PRAGMA table_xinfo({quoted})").fetchall() - # hidden=1/2 are virtual-table shadow columns. Generated columns (3) are - # real semantic ids and remain in the comparison. - return tuple(str(row[1]) for row in rows if int(row[6]) not in (1, 2)) - - -def _table_primary_key(connection: sqlite3.Connection, table: str, columns: tuple[str, ...]) -> tuple[str, ...]: - if table == "actions" and "tool_use_block_id" in columns: - return ("tool_use_block_id",) - if table == "raw_revision_applications": - return ( - "raw_id", - "session_id", - "decision", - "source_revision", - "accepted_source_revision", - ) - rows = connection.execute(f"PRAGMA table_xinfo({_quote_identifier(table)})").fetchall() - primary_key = [(int(row[5]), str(row[1])) for row in rows if int(row[5]) > 0 and int(row[6]) not in (1, 2)] - if primary_key: - return tuple(name for _position, name in sorted(primary_key)) - for preferred in ("tool_use_block_id", "session_id", "message_id", "block_id", "event_id", "policy_id"): - if preferred in columns: - return (preferred,) - return columns - - -def _selected_session_ids( - current: sqlite3.Connection, - candidate: sqlite3.Connection, - requested: Iterable[str], -) -> tuple[str, ...]: - explicit = tuple(dict.fromkeys(str(value) for value in requested)) - if explicit: - return tuple(sorted(explicit)) - values: set[str] = set() - for connection in (current, candidate): - if "sessions" not in _read_model_tables(connection): - continue - rows = connection.execute("SELECT session_id FROM sessions ORDER BY session_id").fetchall() - values.update(str(row[0]) for row in rows) - return tuple(sorted(values)) - - -def _compare_table( - table: str, - current: sqlite3.Connection, - candidate: sqlite3.Connection, - *, - session_ids: tuple[str, ...], - expected: tuple[ExpectedDifference, ...], - delta_expectations: tuple[DeltaExpectation, ...] = (), -) -> list[RowDifference]: - current_columns = _table_columns(current, table) - candidate_columns = _table_columns(candidate, table) - columns = tuple(column for column in current_columns if column in candidate_columns) - if not columns: - return [] - scope_columns = ( - ("src_session_id",) - if table == "session_links" and "src_session_id" in columns - else tuple(column for column in _SESSION_SCOPE_COLUMNS if column in columns) - ) - if not scope_columns: - return [] - current_rows = _table_rows(current, table, columns, scope_columns, session_ids) - candidate_rows = _table_rows(candidate, table, columns, scope_columns, session_ids) - keys = sorted(set(current_rows).union(candidate_rows), key=repr) - primary_key = _table_primary_key(current, table, columns) - differences: list[RowDifference] = [] - for key in keys: - before = current_rows.get(key) - after = candidate_rows.get(key) - if before == after: - continue - if before is None: - operation = DifferenceOperation.ADDED - changed_columns = tuple(after) if after is not None else () - elif after is None: - operation = DifferenceOperation.REMOVED - changed_columns = tuple(before) - else: - operation = DifferenceOperation.CHANGED - changed_columns = tuple(column for column in columns if before.get(column) != after.get(column)) - differences.append( - _build_difference( - table=table, - operation=operation, - identity=tuple((column, key[index]) for index, column in enumerate(primary_key)), - before=before, - after=after, - changed_columns=changed_columns, - expected=expected, - delta_expectations=delta_expectations, - ) - ) - return differences - - -def _build_difference( - *, - table: str, - operation: DifferenceOperation, - identity: tuple[tuple[str, object], ...], - before: dict[str, object] | None, - after: dict[str, object] | None, - changed_columns: tuple[str, ...], - expected: tuple[ExpectedDifference, ...], - delta_expectations: tuple[DeltaExpectation, ...] = (), -) -> RowDifference: - matching = next( - ( - item - for item in expected - if item.matches(table=table, operation=operation, identity=identity, changed_columns=changed_columns) - ), - None, - ) - delta_match = ( - None - if matching is not None - else next( - ( - item - for item in delta_expectations - if item.matches( - table=table, - operation=operation, - identity=identity, - changed_columns=changed_columns, - ) - ), - None, - ) - ) - if matching is None and delta_match is not None: - return RowDifference( - table=table, - operation=operation, - identity=identity, - before=before, - after=after, - changed_columns=changed_columns, - classification=DifferenceClassification.EXPECTED, - rationale=( - f"index delta {delta_match.version}: declared canary change for " - f"{delta_match.table}.{'/'.join(delta_match.columns)}" - ), - ) - return RowDifference( - table=table, - operation=operation, - identity=identity, - before=before, - after=after, - changed_columns=changed_columns, - classification=DifferenceClassification.EXPECTED - if matching is not None - else DifferenceClassification.UNEXPECTED, - rationale=( - f"{matching.bead_ref}: {matching.rationale}" - if matching is not None - else "no reviewed bead or delta declaration matched this difference" - ), - ) - - -def _table_rows( - connection: sqlite3.Connection, - table: str, - columns: tuple[str, ...], - scope_columns: tuple[str, ...], - session_ids: tuple[str, ...], -) -> dict[tuple[object, ...], dict[str, object]]: - volatile_columns = _VOLATILE_COLUMNS.union(_VOLATILE_COLUMNS_BY_TABLE.get(table, ())) - selected_columns = tuple(column for column in columns if column not in volatile_columns) - quoted_columns = ", ".join(_quote_identifier(column) for column in columns) - query = f"SELECT {quoted_columns} FROM {_quote_identifier(table)}" - parameters: tuple[str, ...] = () - if session_ids: - placeholders = ", ".join("?" for _ in session_ids) - query += " WHERE " + " OR ".join(f"{_quote_identifier(column)} IN ({placeholders})" for column in scope_columns) - parameters = session_ids * len(scope_columns) - result: dict[tuple[object, ...], dict[str, object]] = {} - primary_key = _table_primary_key(connection, table, columns) - for row in connection.execute(query, parameters): - normalized = {column: _normalize_value(column, row[column]) for column in selected_columns} - key = tuple(_normalize_value(column, row[column]) for column in primary_key) - result[key] = normalized - return result - - -def _normalize_value(column: str, value: Any) -> object: - if value is None: - return None - if isinstance(value, (bytes, bytearray, memoryview)): - return bytes(value).hex() - if isinstance(value, str) and column.endswith("_json"): - try: - return json.dumps(json.loads(value), ensure_ascii=False, sort_keys=True, separators=(",", ":")) - except (TypeError, ValueError): - return value - return value - - -def _quote_identifier(value: str) -> str: - return '"' + value.replace('"', '""') + '"' - - -__all__ = [ - "CANARY_COMPARISON_ATTESTATION_SCHEMA_VERSION", - "CANARY_REPORT_SCHEMA_VERSION", - "LEGACY_CANARY_REPORT_SCHEMA_VERSION", - "STRICT_CANARY_REPORT_SCHEMA_VERSION", - "CanaryAuthorityKind", - "CanaryComparisonAttestation", - "CanaryDifferenceReview", - "CanaryDiffReport", - "CanaryRunResult", - "CanarySelection", - "CanarySelectionError", - "DurableCanaryReport", - "DeltaExpectation", - "DifferenceClassification", - "DifferenceOperation", - "ExpectedDifference", - "RowDifference", - "UnclassifiedCanaryDiffError", - "approve_canary_report", - "approve_canary_report_under_daemon_ownership", - "compare_reindex_generations", - "index_delta_expectations", - "load_canary_report", - "run_reindex_canary", - "seal_canary_comparison", - "seal_canary_comparison_under_daemon_ownership", - "select_canary_sessions", - "write_canary_report", -] diff --git a/polylogue/maintenance/replay.py b/polylogue/maintenance/replay.py deleted file mode 100644 index e3c7d65418..0000000000 --- a/polylogue/maintenance/replay.py +++ /dev/null @@ -1,1560 +0,0 @@ -"""Idempotent and resumable replay execution for maintenance backfills. - -This is the *execute* half of the maintenance planner (issue #1147). -It turns a :class:`~polylogue.maintenance.planner.BackfillOperation` -into a sequence of per-target executions that: - -* converge — running the same operation twice in a row produces no - additional changes after the first pass converges (the underlying - repair functions are idempotent by construction; the loop adds the - multi-target convergence guarantee); -* resume — an interrupted operation can be re-invoked with the same - ``operation_id`` and will pick up at the first target it had not - completed, skipping the targets already marked done; -* isolate failures — a target that raises is recorded as a bounded - :class:`~polylogue.maintenance.planner.FailureSample` and the - executor continues with the remaining targets instead of aborting - the whole operation; -* report progress — every checkpoint reports - ``operation_id``/current target/processed-vs-total/last cursor and - in-flight failure count via the existing structured logger and via - the returned :class:`BackfillOperation`. - -Persisted state records target identities in ``completed_targets``. New -checkpoints use those successful identities as the sole work coordinate and -retain ``cursor="target:0"`` only as a validated migration field for older -states. On resume, the executor derives pending work from completed identities -against the current catalog before any handler runs, so removing or reordering -a target cannot shift work onto an unrelated target. An unverifiable -historical state fails closed. The state is persisted to a small JSON file -under the configured archive root. - -The state file is the only durable resume substrate this module -introduces; it lives alongside the archive and is removed when the -operation completes successfully. -""" - -from __future__ import annotations - -import sqlite3 -import uuid -from collections.abc import Callable, Iterable -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import TYPE_CHECKING, Final, cast - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.core.json import JSONDocument, dumps, json_document, loads -from polylogue.core.protocols import ProgressCallback as StageProgressCallback -from polylogue.logging import get_logger - -if TYPE_CHECKING: - from polylogue.sources.revision_backfill import RawParsePrefetchCache -from polylogue.maintenance.failure_routing import resolve_maintenance_failures, route_failure_sample -from polylogue.maintenance.invalidation import InvalidationReason -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.maintenance.planner import ( - MAX_FAILURE_SAMPLES, - BackfillKind, - BackfillOperation, - BoundedFailureSamples, - FailureSample, - MaintenanceScope, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter, unsupported_scope_dimensions -from polylogue.maintenance.targets import ( - CLEANUP_TARGETS, - MAINTENANCE_TARGET_NAMES, - SAFE_REPAIR_TARGETS, - MaintenanceTargetSpec, - build_maintenance_target_catalog, -) -from polylogue.storage import repair as _repair -from polylogue.storage.repair import ( - RepairResult, - offline_maintenance_blockers, - repair_empty_sessions, - repair_session_insights, -) - -logger = get_logger(__name__) - -#: Sentinel cursor value meaning "operation completed; nothing left to do." -CURSOR_DONE: Final[str] = "done" - -#: Cursor prefix retained for legacy state migration. New checkpoints write -#: ``target:0`` and derive pending work from successful target identities; -#: older ``target:N`` values are interpreted against their persisted target -#: tuple only after strict validation. -_CURSOR_TARGET_PREFIX: Final[str] = "target:" - -#: Subdirectory under :attr:`Config.archive_root` used for replay state -#: files. One JSON file per ``operation_id``. -_STATE_DIRNAME: Final[str] = ".maintenance-state" - -#: Spill-cache bound for the offline ``rebuild-index`` command's one-shot, -#: archive-wide census (``selected_raw_ids=None`` has no resource envelope, -#: so this is independent of any envelope block). It only avoids the -#: census-then-replay double parse for typical raws; oversized raws are -#: deliberately still not cached (see ``_ParsedSessionSpill``), so the cache -#: never becomes a second archive-wide materialization. -_REBUILD_CENSUS_SPILL_CACHE_BYTES: Final[int] = 512 * 1024 * 1024 - -#: Rebuild-scale commit batching (polylogue-amg1/oikv machinery; the rebuild -#: caller previously never opted in, paying one fsync'd commit per censused -#: raw and per replayed cohort -- thousands per page). A crash discards at -#: most one open batch and the resume reprocesses it from scratch (contract -#: pinned by test_backfill_resumes_after_replay_batch_crash_discards_whole_ -#: batch_cleanly et al.), so the loss window is bounded and cheap. -_REBUILD_COMMIT_BATCH_UNITS: Final[int] = 200 - - -# --------------------------------------------------------------------------- -# Target dispatch -# --------------------------------------------------------------------------- - - -#: Type alias for repair functions that take (config, dry_run) -> RepairResult. -_RepairFn = Callable[[Config, bool], RepairResult] - - -def _replay_handler_for(target_name: str, *, replayable: bool) -> _RepairFn | None: - """Look up the concrete repair callable for one target. - - The catalog (:mod:`polylogue.maintenance.targets`) is the single - source for target *identity* and replay *capability* - (``MaintenanceTargetSpec.replayable``); the concrete handler - implementation lives in :data:`polylogue.storage.repair.REPAIR_HANDLERS` - (the same dict the non-resumable ``run_selected_maintenance`` path - uses). There is deliberately no independent, hand-maintained replay - dispatch table any more (polylogue-71ey) -- a target that is - ``replayable`` in the catalog but missing from ``REPAIR_HANDLERS`` - (or vice versa) is a bug caught by - ``tests/unit/maintenance/test_targets.py``'s catalog-equality test, - not silently tolerated here. - """ - if not replayable: - return None - return _repair.REPAIR_HANDLERS.get(target_name) - - -def supported_replay_targets() -> tuple[str, ...]: - """Names of targets the replay executor knows how to execute. - - Derived from the catalog's declared ``replayable`` targets, filtered - to those with a real handler in - :data:`polylogue.storage.repair.REPAIR_HANDLERS`. Stable contract - for callers and tests. - """ - catalog = build_maintenance_target_catalog() - return tuple( - spec.name for spec in catalog.specs if _replay_handler_for(spec.name, replayable=spec.replayable) is not None - ) - - -async def rebuild_index_from_source( - config: Config, - *, - raw_ids: list[str] | None, - raw_batch_size: int, - ingest_workers: int | None, - materialize: bool, - progress_callback: StageProgressCallback | None, - owned_inactive_generation: tuple[str, str] | None = None, - bulk_fts: bool = False, - bulk_build: bool = False, - prefetch_cache: RawParsePrefetchCache | None = None, - deadline_check: Callable[[], None] | None = None, -) -> dict[str, object]: - """Replay retained bytes through typed revision authority. - - ``raw_ids`` is an initial scheduling hint, not an authority boundary: the - replay expands to complete logical cohorts so a partial selection cannot - make an older snapshot look newest. - - ``bulk_fts`` (polylogue-crd8, default ``False``) enables the guard-gated - bulk FTS mode for whale prefix-sharing lineage cascades encountered during - replay; see ``backfill_historical_revision_evidence``. The offline - ``rebuild-index`` maintenance command passes ``True``. - - ``bulk_build`` (polylogue-v6i3, default ``False``) enables the broader - bulk-generation-build lifecycle -- skip every per-session - messages_fts/blocks_command_trigram/action_pairs/delegation_facts - refresh during replay, deferred to one archive-wide repopulate at - readiness. The offline ``rebuild-index`` maintenance command passes - ``True``. - - ``prefetch_cache`` (polylogue-gd6v, default ``None``) lets a caller - substitute parse output already computed off the writer hold (the - daemon's ``DaemonParseStage``) for this pass's census phase; see - ``backfill_historical_revision_evidence``. - - ``deadline_check`` (polylogue-uhgm, default ``None``) is forwarded - unchanged to ``backfill_historical_revision_evidence``'s own parameter of - the same name -- see its docstring for the exact interruption contract - (checked between REPLAY cohorts, not only after this call returns). - """ - if raw_batch_size <= 0: - raise ValueError("raw_batch_size must be positive") - # The caller owns page selection. Do not widen its bounded page here: - # revision backfill may still expand that page to a complete authority - # cohort, which is required for correct newest-revision selection. - del materialize - import asyncio - - from polylogue.pipeline.services.process_pool import resolve_parse_worker_count - from polylogue.sources.revision_backfill import ( - backfill_historical_revision_evidence, - split_parse_and_apply_seconds, - ) - - resolved_ingest_workers = ingest_workers if ingest_workers is not None else resolve_parse_worker_count() - if progress_callback is not None: - progress_callback(0, "classifying retained raw revision cohorts") - result = await asyncio.to_thread( - backfill_historical_revision_evidence, - Path(config.archive_root), - selected_raw_ids=raw_ids, - owned_inactive_generation=owned_inactive_generation, - max_cached_payload_bytes=_REBUILD_CENSUS_SPILL_CACHE_BYTES, - ingest_workers=resolved_ingest_workers, - commit_batch_size=_REBUILD_COMMIT_BATCH_UNITS, - # Replay-phase batching was previously pinned to 1 (per-cohort - # commits) because a cohort with pending attachment blobs flushes - # publication receipts on a SEPARATE source.db connection that waits - # at BEGIN IMMEDIATE behind the batch's held write lock. Both apply - # paths now commit the open batch before any NON-EMPTY flush - # (``ArchiveBlobPublisher.has_pending``), so attachment-free cohorts - # -- the overwhelming bulk of a rebuild -- share one commit per - # batch and blob-carrying cohorts degrade to the old per-cohort - # boundary instead of deadlocking. Measured: per-cohort commits were - # ~38% of a rebuild pass's wall (4 transactions per cohort). - replay_commit_batch_size=_REBUILD_COMMIT_BATCH_UNITS, - bulk_fts=bulk_fts, - bulk_build=bulk_build, - prefetch_cache=prefetch_cache, - deadline_check=deadline_check, - ) - if progress_callback is not None: - progress_callback(result.replayed_logical_sources, "revision replay complete") - parse_s, apply_s = split_parse_and_apply_seconds(result.stage_timings_s) - return { - "scanned_raw_count": result.scanned, - "classified_full_count": result.classified_full, - "replayed_logical_source_count": result.replayed_logical_sources, - "quarantined_raw_count": result.quarantined, - "adoption_deferred_raw_count": result.adoption_deferred, - "authority_selection_expanded": True, - "scheduled_raw_count": len(raw_ids) if raw_ids is not None else None, - "raw_batch_size": raw_batch_size, - "ingest_workers": resolved_ingest_workers, - # polylogue-623q: the parse-vs-apply split. ``parse_s`` is read-only - # decode (census + spill-cache reload of already-parsed content), - # embarrassingly parallel and scaling with ``ingest_workers``. - # ``apply_s`` is everything charged to the single SQLite writer - # (index/FTS/projection writes) -- see - # ``revision_backfill.split_parse_and_apply_seconds``. - "parse_s": round(parse_s, 6), - "apply_s": round(apply_s, 6), - "stage_timings_s": {key: round(value, 6) for key, value in result.stage_timings_s.items()}, - # The timings ledger says where wall time went; these aggregate-only - # values say whether an outsized parsed tree stayed in the bounded - # resident tier or fell back to spill/reparse. - "whale_envelope": result.whale_envelope, - } - - -class UnsupportedReplayTargetError(RuntimeError): - """Raised when a resolved target has no replay dispatch entry.""" - - -class InvalidReplayStateError(RuntimeError): - """Raised when persisted replay state cannot be trusted for resumption.""" - - -class IncompatibleReplayStateError(RuntimeError): - """Raised when persisted replay identities cannot map to current targets.""" - - -def _failed_replay_state( - *, - operation_id: str, - targets: tuple[str, ...], - scope_filter: MaintenanceScopeFilter, - message: str, - kind: str, -) -> BackfillOperation: - """Build a typed failure without invoking a maintenance handler.""" - started_at = datetime.now(timezone.utc).isoformat() - sample = FailureSample(kind=kind, locator=f"operation:{operation_id}", message=message) - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=targets, - status=OperationStatus.FAILED, - progress=0.0, - started_at=started_at, - completed_at=started_at, - error=message, - scope=MaintenanceScope(targets=targets, filter=scope_filter), - reason=InvalidationReason.UNKNOWN, - failure_samples=BoundedFailureSamples.from_samples((sample,)), - metrics={"repaired_count": 0.0}, - ) - - -# --------------------------------------------------------------------------- -# Cursor encoding -# --------------------------------------------------------------------------- - - -def _encode_cursor(next_target_index: int) -> str: - """Encode the next target index as an opaque ``target:N`` string.""" - return f"{_CURSOR_TARGET_PREFIX}{next_target_index}" - - -_INVALID_CURSOR_MESSAGE = "Persisted replay state has an invalid target cursor" -_INCOMPATIBLE_CURSOR_MESSAGE = "Persisted replay state has an incompatible target cursor" - - -def _cursor_syntax_error(cursor: object) -> str | None: - """Validate explicit cursor syntax without requiring a target catalog. - - Range validation is deliberately separate: an explicit cursor must be - rejected before target resolution, blocker checks, or state hydration, but - ``target:N`` cannot be range-checked until the current target set is known. - """ - if not isinstance(cursor, str) or cursor == "": - return _INVALID_CURSOR_MESSAGE - if cursor == CURSOR_DONE: - return None - if not cursor.startswith(_CURSOR_TARGET_PREFIX): - return _INVALID_CURSOR_MESSAGE - head = cursor[len(_CURSOR_TARGET_PREFIX) :].split(":", 1)[0] - try: - index = int(head) - except ValueError: - return _INVALID_CURSOR_MESSAGE - if index < 0: - return _INVALID_CURSOR_MESSAGE - return None - - -def _strict_cursor(cursor: str | None, *, total_targets: int) -> tuple[int | None, str | None]: - """Parse a cursor without converting corruption into a destructive run. - - ``None`` is the in-memory marker for a genuinely new operation. An empty - string, by contrast, is a persisted/explicit cursor value and therefore - malformed state; treating it as ``target:0`` would silently turn a corrupt - resume into a fresh replay. - """ - if cursor is None: - return 0, None - syntax_error = _cursor_syntax_error(cursor) - if syntax_error is not None: - return None, syntax_error - assert isinstance(cursor, str) - if cursor == CURSOR_DONE: - return total_targets, None - head = cursor[len(_CURSOR_TARGET_PREFIX) :].split(":", 1)[0] - index = int(head) - if index > total_targets: - return None, _INCOMPATIBLE_CURSOR_MESSAGE - return index, None - - -def _resume_pending_specs( - specs: tuple[MaintenanceTargetSpec, ...], - persisted: JSONDocument, - *, - cursor_override: str | None = None, -) -> tuple[tuple[MaintenanceTargetSpec, ...] | None, tuple[str, ...], str | None]: - """Map persisted completion identities onto the current target catalog.""" - raw_targets = persisted.get("targets") - if not isinstance(raw_targets, list) or not all(isinstance(name, str) for name in raw_targets): - return None, (), "Persisted replay state has no valid target identity list" - old_targets = cast(tuple[str, ...], tuple(raw_targets)) - if len(set(old_targets)) != len(old_targets): - return None, (), "Persisted replay state has duplicate target identities" - - has_completion_identities = "completed_targets" in persisted - completed_raw = persisted.get("completed_targets") - if has_completion_identities: - if not isinstance(completed_raw, list) or not all(isinstance(name, str) for name in completed_raw): - return None, (), "Persisted replay state has invalid completed target identities" - completed_order = cast(tuple[str, ...], tuple(completed_raw)) - if len(set(completed_order)) != len(completed_order) or not set(completed_order) <= set(old_targets): - return None, (), "Persisted replay state has incompatible completed target identities" - completed = set(completed_order) - if cursor_override is None and "cursor" not in persisted: - return None, (), "Persisted replay state has an invalid target cursor" - cursor = cursor_override if cursor_override is not None else persisted.get("cursor") - if not isinstance(cursor, str): - return None, (), "Persisted replay state has an invalid target cursor" - # ``target:N`` is a coordinate in the persisted target history, not - # in the narrowed pending subset. Validate it against that history; - # identity completion below remaps the current selection without - # making a valid historical cursor invalid merely because fewer - # targets remain today. - _, cursor_error = _strict_cursor(cursor, total_targets=len(old_targets)) - if cursor_error is not None: - return None, (), cursor_error - # New checkpoints use identities as the sole coordinate. The cursor is - # retained only as a validated migration field and never advances more - # identities after filtering. - return tuple(spec for spec in specs if spec.name not in completed), completed_order, None - - # Legacy checkpoints did not persist completed identities. Successful - # result records are authoritative when available; the positional cursor - # is retained only for the older in-progress form. A legacy ``done`` - # cursor is not evidence that every attempted target succeeded (a failed - # target used to advance it), so it must fail closed unless success - # records identify the completed work. - raw_results = persisted.get("results", []) - successful_from_results: list[str] = [] - if isinstance(raw_results, list): - for result in raw_results: - if not isinstance(result, dict): - continue - name = result.get("name") - if isinstance(name, str) and result.get("success") is True and name in old_targets: - successful_from_results.append(name) - completed_order = tuple(dict.fromkeys(successful_from_results)) - cursor = cursor_override if cursor_override is not None else persisted.get("cursor") - if cursor_override is None and "cursor" not in persisted: - return None, (), "Persisted replay state has an invalid target cursor" - if not isinstance(cursor, str): - return None, (), "Persisted replay state has an invalid target cursor" - if cursor == CURSOR_DONE: - if not completed_order: - return None, (), "Legacy replay state has no authoritative successful targets" - completed = set(completed_order) - return tuple(spec for spec in specs if spec.name not in completed), completed_order, None - index, cursor_error = _strict_cursor(cursor, total_targets=len(old_targets)) - if cursor_error is not None or index is None: - return None, (), cursor_error or "Persisted replay state has no valid target cursor" - # A legacy failure sample proves that the positional prefix may include a - # failed attempt. Without authoritative success records, inferring that - # prefix would silently skip retryable work, so fail closed. - raw_failure_records = persisted.get("failure_samples") - if raw_failure_records is None and isinstance(persisted.get("operation"), dict): - nested_failures = cast(JSONDocument, persisted["operation"]).get("failure_samples") - if isinstance(nested_failures, dict): - raw_failure_records = nested_failures.get("samples", []) - else: - raw_failure_records = nested_failures - if not completed_order and index > 0 and raw_failure_records: - return None, (), "Legacy replay state has failure samples but no authoritative successful targets" - # A non-terminal positional cursor remains compatible with historical - # interrupted checkpoints. Prefer explicit successful records, which also - # handles a cursor that was advanced past a reported failure. - if not completed_order: - completed_order = old_targets[:index] - completed = set(completed_order) - return tuple(spec for spec in specs if spec.name not in completed), completed_order, None - - -# --------------------------------------------------------------------------- -# State persistence -# --------------------------------------------------------------------------- - - -def _state_dir(config: Config) -> Path: - return Path(config.archive_root) / _STATE_DIRNAME - - -def state_path_for(config: Config, operation_id: str) -> Path: - """Path of the JSON state file for ``operation_id``.""" - safe_operation_id = validate_operation_id(operation_id) - return _state_dir(config) / f"{safe_operation_id}.json" - - -def _write_state(path: Path, payload: JSONDocument) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_text(dumps(payload)) - tmp.replace(path) - - -def load_state(config: Config, operation_id: str) -> JSONDocument | None: - """Load a previously persisted operation state, or ``None``. - - A present but malformed state is distinct from an absent state. Callers - must fail closed rather than treating corruption as a fresh operation. - """ - path = state_path_for(config, operation_id) - if not path.exists(): - return None - try: - raw = loads(path.read_text()) - except Exception as exc: - logger.warning( - "replay_state_unparseable", - operation_id=operation_id, - path=str(path), - error=str(exc), - ) - raise InvalidReplayStateError("Persisted replay state is not a JSON object") from exc - if not isinstance(raw, dict): - logger.warning( - "replay_state_unparseable", - operation_id=operation_id, - path=str(path), - ) - raise InvalidReplayStateError("Persisted replay state is not a JSON object") - return raw - - -def clear_state(config: Config, operation_id: str) -> None: - """Best-effort removal of the on-disk state file for an operation.""" - path = state_path_for(config, operation_id) - try: - path.unlink(missing_ok=True) - except OSError as exc: - logger.warning( - "replay_state_clear_failed", - operation_id=operation_id, - error=str(exc), - ) - - -# --------------------------------------------------------------------------- -# Progress reporting -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ReplayProgress: - """One progress checkpoint emitted by :func:`execute_replay`. - - The shape is consumer-stable: CLI and daemon surfaces format the - same fields. ``processed`` counts targets fully attempted (regardless - of success); ``total`` is the resolved target count. ``cursor`` is - the cursor the executor would persist *after* this checkpoint, so - callers can crash between checkpoints and resume cleanly from the - last one they observed. - """ - - operation_id: str - target: str - processed: int - total: int - cursor: str - in_flight_failures: int - progress_amount: int | None = None - progress_desc: str | None = None - - def to_dict(self) -> JSONDocument: - payload: dict[str, object] = { - "operation_id": self.operation_id, - "target": self.target, - "processed": self.processed, - "total": self.total, - "cursor": self.cursor, - "in_flight_failures": self.in_flight_failures, - } - if self.progress_amount is not None: - payload["progress_amount"] = self.progress_amount - if self.progress_desc is not None: - payload["progress_desc"] = self.progress_desc - return json_document(payload) - - -ProgressCallback = Callable[[ReplayProgress], None] - - -# --------------------------------------------------------------------------- -# Replay executor -# --------------------------------------------------------------------------- - - -@dataclass -class _ReplayState: - """Mutable per-run state assembled during :func:`execute_replay`.""" - - operation_id: str - targets: tuple[str, ...] - target_history: tuple[str, ...] - cursor: str - started_at: str - completed_targets: list[str] = field(default_factory=list) - attempted_count: int = 0 - results: list[JSONDocument] = field(default_factory=list) - failures: list[FailureSample] = field(default_factory=list) - failures_truncated: bool = False - repaired_total: int = 0 - metrics: dict[str, float] = field(default_factory=dict) - metric_baseline_results: int = 0 - scope_filter: MaintenanceScopeFilter = field(default_factory=MaintenanceScopeFilter) - - def progress_for(self, target: str, processed: int) -> ReplayProgress: - return ReplayProgress( - operation_id=self.operation_id, - target=target, - processed=processed, - total=len(self.targets), - cursor=self.cursor, - in_flight_failures=len(self.failures), - ) - - -def _numeric_metric(value: object) -> float | None: - if isinstance(value, (int, float)) and not isinstance(value, bool): - return float(value) - return None - - -def _aggregate_result_metrics(results: list[JSONDocument]) -> dict[str, float]: - """Aggregate numeric metrics from repair result rows.""" - metrics: dict[str, float] = {} - for result in results: - raw = result.get("metrics") - if not isinstance(raw, dict): - continue - for key, value in raw.items(): - metric_value = _numeric_metric(value) - if metric_value is None: - continue - metric_key = str(key) - if metric_key.endswith("_max_blob_bytes"): - metrics[metric_key] = max(metrics.get(metric_key, 0.0), metric_value) - else: - metrics[metric_key] = metrics.get(metric_key, 0.0) + metric_value - return metrics - - -def _operation_metrics(state: _ReplayState) -> dict[str, float]: - # Compute metrics only for results appended after hydration. Persisted - # metrics already aggregate the earlier rows, so adding those rows again - # would double-count them. - result_metrics = _aggregate_result_metrics(state.results[state.metric_baseline_results :]) - - metrics = dict(state.metrics) - - for key, value in result_metrics.items(): - if key.endswith("_max_blob_bytes"): - metrics[key] = max(metrics.get(key, 0.0), value) - else: - metrics[key] = metrics.get(key, 0.0) + value - metrics["repaired_count"] = float(state.repaired_total) - return metrics - - -def _hydrate_persisted_receipt( - persisted: JSONDocument, -) -> tuple[str | None, list[JSONDocument], list[FailureSample], bool, int, dict[str, float], str | None]: - """Validate and hydrate cumulative receipt fields from a state file.""" - started_at = persisted.get("started_at") - operation = persisted.get("operation") - if started_at is None and isinstance(operation, dict): - started_at = operation.get("started_at") - if started_at is not None and not isinstance(started_at, str): - return None, [], [], False, 0, {}, "Persisted replay state has invalid started_at" - - raw_results = persisted.get("results", []) - if not isinstance(raw_results, list) or not all(isinstance(item, dict) for item in raw_results): - return None, [], [], False, 0, {}, "Persisted replay state has invalid results" - results = cast(list[JSONDocument], raw_results) - - raw_failures = persisted.get("failure_samples") - failures_truncated = False - if isinstance(operation, dict): - nested = operation.get("failure_samples") - if isinstance(nested, dict): - failures_truncated = nested.get("truncated") is True - if raw_failures is None: - raw_failures = nested.get("samples", []) - if raw_failures is None: - raw_failures = [] - if not isinstance(raw_failures, list): - return None, [], [], False, 0, {}, "Persisted replay state has invalid failure samples" - failures: list[FailureSample] = [] - for item in raw_failures: - if not isinstance(item, dict) or not all( - isinstance(item.get(key), str) for key in ("kind", "locator", "message") - ): - return None, [], [], False, 0, {}, "Persisted replay state has invalid failure samples" - failures.append( - FailureSample( - kind=cast(str, item["kind"]), - locator=cast(str, item["locator"]), - message=cast(str, item["message"]), - ) - ) - failures_truncated = failures_truncated or len(failures) > MAX_FAILURE_SAMPLES - failures = list(BoundedFailureSamples.from_samples(failures).samples) - - raw_repaired = persisted.get("repaired_count", 0) - if not isinstance(raw_repaired, (int, float)) or isinstance(raw_repaired, bool): - return None, [], [], False, 0, {}, "Persisted replay state has invalid repaired count" - raw_metrics = persisted.get("metrics") - # Older registry snapshots may carry an empty top-level metrics object - # while the nested operation snapshot has the cumulative aggregate. - if (raw_metrics is None or raw_metrics == {}) and isinstance(operation, dict): - nested_metrics = operation.get("metrics") - if nested_metrics is not None: - raw_metrics = nested_metrics - if raw_metrics is None: - raw_metrics = {} - if not isinstance(raw_metrics, dict): - return None, [], [], False, 0, {}, "Persisted replay state has invalid metrics" - metrics: dict[str, float] = {} - for key, value in raw_metrics.items(): - if not isinstance(value, (int, float)) or isinstance(value, bool): - return None, [], [], False, 0, {}, "Persisted replay state has invalid metrics" - metrics[str(key)] = float(value) - return started_at, results, failures, failures_truncated, int(raw_repaired), metrics, None - - -def _has_persisted_metric_aggregate(persisted: JSONDocument) -> bool: - """Return whether persisted metrics are an explicit aggregate. - - Older checkpoints omitted the aggregate while still retaining metric-bearing - result rows. An explicit ``metrics: {}``, however, means the writer - intentionally persisted an empty aggregate and must not be reconstructed - from those rows. - """ - marker = object() - raw_metrics: object = persisted.get("metrics", marker) - operation = persisted.get("operation") - if (raw_metrics is marker or raw_metrics == {}) and isinstance(operation, dict): - nested_metrics = operation.get("metrics", marker) - if nested_metrics is not marker and nested_metrics is not None: - raw_metrics = nested_metrics - return raw_metrics is not marker and raw_metrics is not None - - -def _scope_identity(scope_filter: MaintenanceScopeFilter) -> tuple[object, ...]: - """Return a semantic, order-independent scope identity.""" - payload = scope_filter.model_dump(mode="python", exclude_none=False) - session_ids = payload.get("session_ids") - if session_ids is not None: - payload["session_ids"] = tuple(sorted(session_ids)) - for key, value in payload.items(): - if isinstance(value, tuple) and len(value) == 2: - normalized: list[object] = [] - for item in value: - if isinstance(item, datetime): - instant = item if item.tzinfo is not None else item.replace(tzinfo=timezone.utc) - normalized.append(instant.timestamp()) - else: - normalized.append(item) - payload[key] = tuple(normalized) - elif isinstance(value, Path): - payload[key] = str(value) - return tuple((key, payload[key]) for key in sorted(payload)) - - -def _validate_replay_context( - persisted: JSONDocument, - *, - dry_run: bool, - scope_filter: MaintenanceScopeFilter, -) -> str | None: - """Reject a resume whose execution authority changed mid-operation.""" - persisted_mode = persisted.get("dry_run") - if persisted_mode is not None and (not isinstance(persisted_mode, bool) or persisted_mode != dry_run): - return "Persisted replay execution mode does not match the requested mode" - - persisted_scope = persisted.get("scope_filter") - if persisted_scope is None: - if not scope_filter.is_empty(): - return "Persisted replay state has no scope filter for a scoped resume" - return None - if not isinstance(persisted_scope, dict): - return "Persisted replay state has invalid scope filter" - try: - stored_filter = MaintenanceScopeFilter.from_dict(persisted_scope) - except Exception as exc: - return f"Persisted replay state has invalid scope filter: {exc}" - if _scope_identity(stored_filter) != _scope_identity(scope_filter): - return "Persisted replay scope does not match the requested scope" - return None - - -def execute_replay( - config: Config, - targets: Iterable[str], - *, - operation_id: str | None = None, - resume_cursor: str | None = None, - dry_run: bool = False, - persist_state: bool = True, - progress_callback: ProgressCallback | None = None, - scope_filter: MaintenanceScopeFilter | None = None, -) -> BackfillOperation: - """Execute (or resume) a backfill replay against the configured archive. - - Parameters - ---------- - config: - Live runtime config. Threaded through to the underlying repair - functions; ``config.archive_root`` is also where state files - are written when ``persist_state=True``. - targets: - Target names to replay. Resolved against the canonical target - catalog; unknown names produce a ``FAILED`` operation with an - explanatory ``error`` and an empty :attr:`BackfillOperation.targets` - (parity with :func:`~polylogue.maintenance.planner.execute_backfill`). - operation_id: - Stable operation identifier. Reuse across invocations to resume - an interrupted run; omit to mint a fresh ``uuid4``. - resume_cursor: - Explicit resume cursor. When ``None`` and an on-disk state file - exists for ``operation_id``, the cursor is loaded from disk so - operators don't need to remember the last cursor value out of - band. Pass an explicit value to override the persisted state. - dry_run: - Forwarded to the underlying repair functions. - persist_state: - When true (the default), each completed target advances the - on-disk cursor file under ``/.maintenance-state/``. - Disable for tests or for callers that own their own state - substrate. - progress_callback: - Optional callback invoked after each per-target checkpoint with - a :class:`ReplayProgress` snapshot. - - Returns - ------- - BackfillOperation - Status is ``COMPLETED`` only when every resolved target returned - ``success=True``. Any failure (raised or repair-reported) - downgrades the operation to ``FAILED`` while still recording the - partial results so callers can resume from the cursor. - """ - - op_id = str(uuid.uuid4()) if operation_id is None else validate_operation_id(operation_id) - requested_resume_cursor = resume_cursor - effective_filter = scope_filter or MaintenanceScopeFilter() - - # Explicit cursors are request validation, not resumable state. Reject - # malformed values before catalog resolution, state hydration, and the - # offline blocker check so daemon state can never change their typed error - # or cause a state-file write. - cursor_syntax_error = _cursor_syntax_error(resume_cursor) if resume_cursor is not None else None - if cursor_syntax_error is not None: - return _failed_replay_state( - operation_id=op_id, - targets=(), - scope_filter=effective_filter, - message=cursor_syntax_error, - kind="InvalidReplayCursor", - ) - - catalog = build_maintenance_target_catalog() - # Empty ``targets`` means "no explicit scope" and expands to the - # documented run-all set (every catalog target); an explicit but - # unresolvable name still fails closed with an empty resolution - # (polylogue-71ey bug 2: targetless ``maintenance run`` used to - # resolve to zero targets and report ``status=failed``). - resolved_specs = catalog.resolve_or_default(tuple(targets)) - resolved_names = tuple(spec.name for spec in resolved_specs) - if resume_cursor is not None and resolved_names: - _, cursor_range_error = _strict_cursor(resume_cursor, total_targets=len(resolved_names)) - if cursor_range_error is not None: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=cursor_range_error, - kind="InvalidReplayCursor", - ) - - if not resolved_names: - return BackfillOperation( - operation_id=op_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=(), - status=OperationStatus.FAILED, - error="No valid targets resolved from input", - scope=MaintenanceScope(targets=(), filter=effective_filter), - ) - if resume_cursor is not None and not isinstance(resume_cursor, str): - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message="Persisted replay state has an invalid target cursor", - kind="InvalidReplayCursor", - ) - - # Load persisted identity metadata before any execution gate. A cursor - # written against a retired catalog must be remapped or rejected before a - # handler can observe the request. - explicit_resume = resume_cursor is not None - persisted: JSONDocument | None = None - if persist_state: - try: - persisted = load_state(config, op_id) - except InvalidReplayStateError as exc: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=str(exc), - kind="InvalidReplayState", - ) - - pending_specs = resolved_specs - completed_targets: tuple[str, ...] = () - target_history = resolved_names - receipt_started_at: str | None = None - prior_results: list[JSONDocument] = [] - prior_failures: list[FailureSample] = [] - prior_failures_truncated = False - prior_repaired_total = 0 - prior_metrics: dict[str, float] = {} - prior_metrics_are_authoritative = True - if persisted is not None: - prior_metrics_are_authoritative = _has_persisted_metric_aggregate(persisted) - ( - receipt_started_at, - prior_results, - prior_failures, - prior_failures_truncated, - prior_repaired_total, - prior_metrics, - receipt_error, - ) = _hydrate_persisted_receipt(persisted) - if receipt_error is not None: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=receipt_error, - kind="InvalidReplayState", - ) - context_error = _validate_replay_context( - persisted, - dry_run=dry_run, - scope_filter=effective_filter, - ) - if context_error is not None: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=context_error, - kind="ReplayContextMismatch", - ) - - target_history = resolved_names - if persisted is not None: - if "targets" not in persisted: - if not explicit_resume: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message="Persisted replay state has no valid target identity list", - kind="IncompatibleReplayState", - ) - else: - raw_history = persisted.get("targets") - if isinstance(raw_history, list) and all(isinstance(name, str) for name in raw_history): - history_names = cast(list[str], raw_history) - target_history = tuple(dict.fromkeys((*history_names, *resolved_names))) - cursor_value = resume_cursor if explicit_resume else persisted.get("cursor") - mapped_pending, completed_targets, resume_error = _resume_pending_specs( - resolved_specs, - persisted, - cursor_override=cursor_value if isinstance(cursor_value, str) else None, - ) - if resume_error is not None or mapped_pending is None: - message = resume_error or "Persisted replay state is incompatible with the current target catalog" - logger.error( - "replay_state_incompatible", - operation_id=op_id, - targets=resolved_names, - error=message, - ) - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=message, - kind="InvalidReplayCursor" if "cursor" in message.lower() else "IncompatibleReplayState", - ) - assert mapped_pending is not None - pending_specs = mapped_pending - # The cursor has been translated by identity. The remaining tuple - # is a fresh positional work list for this run. - resume_cursor = _encode_cursor(0) - elif not explicit_resume: - resume_cursor = None - - # A state carrying target identities validates an explicit cursor against - # that historical coordinate system in ``_resume_pending_specs`` above. - # Without persisted identity history, the current selection is the only - # coordinate system available for an explicit positional cursor. Do this - # after state loading/remapping so persisted cursors are checked against - # their historical target list instead of a narrowed current selection. - if persisted is None and explicit_resume: - _, cursor_range_error = _strict_cursor(requested_resume_cursor, total_targets=len(resolved_names)) - if cursor_range_error is not None: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=cursor_range_error, - kind="InvalidReplayCursor", - ) - - # A target that cannot apply the requested scope is refused permanently, - # so decide that before the offline gate: an invalid scoped request under - # daemon write ownership must not be reported as a transient blocker the - # caller is invited to retry. - refused_specs = tuple( - spec for spec in pending_specs if unsupported_scope_dimensions(effective_filter, target=spec.name) - ) - if refused_specs: - for spec in refused_specs: - prior_failures.append( - FailureSample( - kind="UnsupportedScopeDimension", - locator=f"target:{spec.name}", - message=( - f"Target {spec.name!r} cannot apply scope dimensions: " - f"{', '.join(unsupported_scope_dimensions(effective_filter, target=spec.name))}" - ), - ) - ) - if len(prior_failures) > MAX_FAILURE_SAMPLES: - prior_failures_truncated = True - del prior_failures[MAX_FAILURE_SAMPLES:] - refused_names = {spec.name for spec in refused_specs} - completed_targets = tuple(dict.fromkeys((*completed_targets, *(spec.name for spec in refused_specs)))) - pending_specs = tuple(spec for spec in pending_specs if spec.name not in refused_names) - - blockers = offline_maintenance_blockers( - config, - repair=any(name in SAFE_REPAIR_TARGETS for name in resolved_names), - cleanup=any(name in CLEANUP_TARGETS for name in resolved_names), - dry_run=dry_run, - targets=resolved_names, - ) - if blockers and pending_specs: - samples = tuple( - FailureSample( - kind="OfflineMaintenanceBlocked", - locator=f"target:{result.name}", - message=result.detail, - ) - for result in blockers - ) - started_at = receipt_started_at or datetime.now(timezone.utc).isoformat() - blocker_state = _ReplayState( - operation_id=op_id, - targets=resolved_names, - target_history=target_history, - cursor=_encode_cursor(0), - started_at=started_at, - completed_targets=list(completed_targets), - attempted_count=sum(name in resolved_names for name in completed_targets), - results=[*prior_results, *(result.to_dict() for result in blockers)], - failures=[*prior_failures, *samples][:MAX_FAILURE_SAMPLES], - failures_truncated=prior_failures_truncated or len(prior_failures) + len(samples) > MAX_FAILURE_SAMPLES, - repaired_total=prior_repaired_total + sum(result.repaired_count for result in blockers), - metrics=prior_metrics, - metric_baseline_results=len(prior_results) if prior_metrics_are_authoritative else 0, - scope_filter=effective_filter, - ) - blocker_metrics = _operation_metrics(blocker_state) - blocker_receipt = BackfillOperation( - operation_id=op_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.FAILED, - progress=sum(name in completed_targets for name in resolved_names) / len(resolved_names), - started_at=started_at, - completed_at=datetime.now(timezone.utc).isoformat(), - affected_rows=blocker_state.repaired_total, - results=blocker_state.results, - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=InvalidationReason.UNKNOWN, - failure_samples=BoundedFailureSamples( - samples=tuple(blocker_state.failures), - truncated=blocker_state.failures_truncated, - ), - metrics=blocker_metrics, - ) - if persist_state: - _checkpoint_state( - config=config, - operation_id=op_id, - state=blocker_state, - started_at=started_at, - dry_run=dry_run, - scope_filter=effective_filter, - operation_snapshot=blocker_receipt, - ) - return blocker_receipt - - start_index, cursor_error = _strict_cursor(resume_cursor, total_targets=len(pending_specs)) - if cursor_error is not None or start_index is None: - return _failed_replay_state( - operation_id=op_id, - targets=resolved_names, - scope_filter=effective_filter, - message=cursor_error or "Persisted replay state has an invalid target cursor", - kind="InvalidReplayCursor", - ) - if not completed_targets and start_index: - completed_targets = tuple(spec.name for spec in pending_specs[:start_index]) - started_at = receipt_started_at or datetime.now(timezone.utc).isoformat() - state = _ReplayState( - operation_id=op_id, - targets=resolved_names, - target_history=target_history, - cursor=_encode_cursor(0), - started_at=started_at, - completed_targets=list(completed_targets), - attempted_count=sum(name in resolved_names for name in completed_targets), - results=prior_results, - failures=prior_failures, - failures_truncated=prior_failures_truncated, - repaired_total=prior_repaired_total, - metrics=prior_metrics, - metric_baseline_results=len(prior_results) if prior_metrics_are_authoritative else 0, - scope_filter=effective_filter, - ) - - if not pending_specs: - state.cursor = CURSOR_DONE - completed_at = datetime.now(timezone.utc).isoformat() - terminal_scope_refusal = _has_terminal_scope_refusal(state.failures, resolved_names) - final = BackfillOperation( - operation_id=op_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.FAILED if terminal_scope_refusal else OperationStatus.COMPLETED, - progress=1.0, - started_at=started_at, - completed_at=completed_at, - affected_rows=state.repaired_total, - results=state.results, - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=InvalidationReason.UNKNOWN if terminal_scope_refusal else None, - resume_cursor=CURSOR_DONE, - failure_samples=BoundedFailureSamples( - samples=tuple(state.failures[:MAX_FAILURE_SAMPLES]), - truncated=state.failures_truncated or len(state.failures) > MAX_FAILURE_SAMPLES, - ), - metrics=_operation_metrics(state), - ) - if persist_state: - if terminal_scope_refusal: - _checkpoint_state( - config=config, - operation_id=op_id, - state=state, - started_at=started_at, - dry_run=dry_run, - scope_filter=effective_filter, - operation_snapshot=final, - ) - else: - clear_state(config, op_id) - return final - - logger.info( - "replay_starting", - operation_id=op_id, - targets=resolved_names, - start_index=start_index, - dry_run=dry_run, - ) - - for index in range(start_index, len(pending_specs)): - spec = pending_specs[index] - target_name = spec.name - succeeded = _run_one_target( - state, - spec, - config, - dry_run=dry_run, - scope_filter=effective_filter, - progress_callback=progress_callback, - target_total=len(resolved_names), - processed_before_target=state.attempted_count, - ) - # Every handler invocation, including a raised or reported failure, is - # a fully attempted target for consumer-visible progress. - state.attempted_count += 1 - # Successful identities are authoritative. Failed targets remain in - # the pending set for the next invocation under this operation id. - if succeeded and target_name not in state.completed_targets: - state.completed_targets.append(target_name) - # New checkpoints always use identity completion as the coordinate. - # target:0 is retained only as a validated legacy field. - state.cursor = ( - CURSOR_DONE if all(name in state.completed_targets for name in resolved_names) else _encode_cursor(0) - ) - if persist_state: - _checkpoint_state( - config=config, - operation_id=op_id, - state=state, - started_at=started_at, - dry_run=dry_run, - scope_filter=effective_filter, - ) - if progress_callback is not None: - progress_callback( - state.progress_for( - target_name, - processed=state.attempted_count, - ) - ) - - completed_at = datetime.now(timezone.utc).isoformat() - all_targets_completed = all(name in state.completed_targets for name in resolved_names) - terminal_scope_refusal = _has_terminal_scope_refusal(state.failures, resolved_names) - successful = all_targets_completed and not terminal_scope_refusal - status = OperationStatus.COMPLETED if successful else OperationStatus.FAILED - - completed_current = sum(name in state.completed_targets for name in resolved_names) - progress = completed_current / len(resolved_names) if resolved_names else 1.0 - logger.info( - "replay_completed", - operation_id=op_id, - targets=resolved_names, - dry_run=dry_run, - repaired_count=state.repaired_total, - success=successful, - failure_samples=len(state.failures), - ) - - final = BackfillOperation( - operation_id=op_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=status, - progress=progress, - started_at=started_at, - completed_at=completed_at, - affected_rows=state.repaired_total, - results=state.results, - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=InvalidationReason.UNKNOWN if not successful else None, - resume_cursor=state.cursor, - failure_samples=BoundedFailureSamples( - samples=tuple(state.failures[:MAX_FAILURE_SAMPLES]), - truncated=state.failures_truncated or len(state.failures) > MAX_FAILURE_SAMPLES, - ), - metrics=_operation_metrics(state), - ) - - if persist_state: - if all_targets_completed and not terminal_scope_refusal: - # Success path: drop the state file. The registry's - # default TTL prune will never see this op_id again. - clear_state(config, op_id) - else: - # Failure path: write the final snapshot through the - # checkpoint so operators can inspect a failed run via - # the registry surface without rerunning anything. - _checkpoint_state( - config=config, - operation_id=op_id, - state=state, - started_at=started_at, - dry_run=dry_run, - scope_filter=effective_filter, - operation_snapshot=final, - ) - - return final - - -def _record_failure( - state: _ReplayState, - sample: FailureSample, - *, - target: str, - config: Config, - route: bool = True, -) -> None: - """Append a failure sample and route it to the daemon raw-failure surface. - - The in-memory ``state.failures`` envelope backs the returned - :class:`BackfillOperation`'s :class:`BoundedFailureSamples`. - Request-validation refusals set ``route=False`` because they are - terminal operation outcomes, not maintenance debt. - """ - - state.failures.append(sample) - # Keep the persisted and in-memory envelope bounded across retries, not - # merely at final receipt serialization time. - if len(state.failures) > MAX_FAILURE_SAMPLES: - state.failures_truncated = True - del state.failures[MAX_FAILURE_SAMPLES:] - if route: - route_failure_sample( - sample, - operation_id=state.operation_id, - archive_root=Path(config.archive_root), - target=target, - ) - - -def _has_terminal_scope_refusal(samples: list[FailureSample], targets: tuple[str, ...]) -> bool: - """Return whether a replay over ``targets`` carries a permanent scope refusal. - - A resumed operation may hydrate refusal samples recorded for targets the - caller has since dropped from the request. Those name no target in the - current selection and must not fail a run that no longer includes them. - """ - selected = {f"target:{name}" for name in targets} - return any( - sample.kind == "UnsupportedScopeDimension" - and (not sample.locator.startswith("target:") or sample.locator in selected) - for sample in samples - ) - - -def _run_one_target( - state: _ReplayState, - spec: MaintenanceTargetSpec, - config: Config, - *, - dry_run: bool, - scope_filter: MaintenanceScopeFilter, - progress_callback: ProgressCallback | None, - target_total: int, - processed_before_target: int, -) -> bool: - """Execute one target, recording success or a typed failure sample.""" - - target_name = spec.name - unsupported = unsupported_scope_dimensions(scope_filter, target=target_name) - if unsupported: - message = f"Target {target_name!r} cannot apply scope dimensions: {', '.join(unsupported)}" - _record_failure( - state, - FailureSample(kind="UnsupportedScopeDimension", locator=f"target:{target_name}", message=message), - target=target_name, - config=config, - route=False, - ) - return True - repair_fn = _replay_handler_for(target_name, replayable=spec.replayable) - if repair_fn is None: - reason = ( - spec.non_replayable_reason - if not spec.replayable and spec.non_replayable_reason - else (f"Target {target_name!r} is not yet wired into polylogue.storage.repair.REPAIR_HANDLERS.") - ) - sample = FailureSample( - kind=UnsupportedReplayTargetError.__name__, - locator=f"target:{target_name}", - message=reason, - ) - _record_failure(state, sample, target=target_name, config=config) - logger.warning( - "replay_target_unsupported", - operation_id=state.operation_id, - target=target_name, - ) - return False - - def _emit_target_progress(amount: int, desc: str | None = None) -> None: - if progress_callback is None: - return - progress_callback( - ReplayProgress( - operation_id=state.operation_id, - target=target_name, - processed=processed_before_target, - total=target_total, - cursor=state.cursor, - in_flight_failures=len(state.failures), - progress_amount=int(amount), - progress_desc=desc, - ) - ) - - try: - if target_name == "session_insights" and repair_fn is repair_session_insights: - # The session-insights repair fn understands a narrowed - # session-id scope directly; it also emits lower-level - # materialization progress. Forward both so one large target - # is not silent until the final per-target checkpoint. - result = repair_session_insights( - config, - dry_run, - session_ids=scope_filter.session_ids, - progress_callback=_emit_target_progress, - ) - elif target_name == "empty_sessions" and repair_fn is repair_empty_sessions: - result = repair_empty_sessions( - config, - dry_run, - session_ids=scope_filter.session_ids, - ) - else: - result = repair_fn(config, dry_run) - except (RuntimeError, sqlite3.Error) as exc: - # Per-AC: a single bad target must not abort the rest of the - # operation. Convert the raised exception into a typed failure - # sample so the caller can introspect it without unwinding. - sample = FailureSample( - kind=type(exc).__name__, - locator=f"target:{target_name}", - message=str(exc), - ) - _record_failure(state, sample, target=target_name, config=config) - logger.exception( - "replay_target_failed", - operation_id=state.operation_id, - target=target_name, - error=str(exc), - ) - return False - - state.results.append(result.to_dict()) - state.repaired_total += result.repaired_count - if result.success: - resolved_kinds = (UnsupportedReplayTargetError.__name__,) if dry_run else () - resolve_maintenance_failures(config.archive_root, target=target_name, kinds=resolved_kinds) - if not result.success: - # Repair functions can report failure without raising. Surface - # that as a typed sample too so the FAILED state carries a - # locator and a message instead of an empty samples list. - _record_failure( - state, - FailureSample( - kind="RepairReportedFailure", - locator=f"target:{target_name}", - message=result.detail or "Repair returned success=False", - ), - target=target_name, - config=config, - ) - return result.success - - -def _checkpoint_state( - *, - config: Config, - operation_id: str, - state: _ReplayState, - started_at: str, - dry_run: bool, - scope_filter: MaintenanceScopeFilter, - operation_snapshot: BackfillOperation | None = None, -) -> None: - """Persist the running operation state so a kill-mid-run can resume. - - The payload carries two layers: - - * legacy top-level fields (``operation_id``/``targets``/``cursor``/ - ``started_at``/``updated_at``/``dry_run``/``repaired_count``/ - ``failure_count``/``results``) so the existing resume path keeps - working without conditionals; - * a full :meth:`BackfillOperation.to_dict` snapshot under the - ``operation`` key (issue #1197) so the - :class:`~polylogue.maintenance.registry.MaintenanceOperationRegistry` - can rehydrate the operation envelope without re-running anything. - """ - - snapshot = operation_snapshot or _build_in_progress_snapshot( - operation_id=operation_id, - state=state, - started_at=started_at, - ) - payload = json_document( - { - "operation_id": operation_id, - "targets": list(state.target_history), - "resolved_targets": list(state.targets), - "completed_targets": list(state.completed_targets), - "cursor": state.cursor, - "started_at": started_at, - "updated_at": datetime.now(timezone.utc).isoformat(), - "dry_run": dry_run, - "scope_filter": scope_filter.to_dict(), - "repaired_count": state.repaired_total, - "failure_count": len(state.failures), - "failure_samples": [sample.to_dict() for sample in state.failures], - "results": list(state.results), - "metrics": _operation_metrics(state), - "operation": snapshot.to_dict(), - } - ) - _write_state(state_path_for(config, operation_id), payload) - - -def _build_in_progress_snapshot( - *, - operation_id: str, - state: _ReplayState, - started_at: str, -) -> BackfillOperation: - """Project the in-flight :class:`_ReplayState` onto a :class:`BackfillOperation`. - - Used by :func:`_checkpoint_state` to make sure every state file - carries a rehydratable snapshot even mid-run (before - :func:`execute_replay` has assembled its final return value). The - snapshot status is :data:`OperationStatus.RUNNING` unless the - executor has finished all targets, in which case it surfaces as - :data:`OperationStatus.COMPLETED` / :data:`OperationStatus.FAILED` - based on the in-flight failure count. - """ - - total = len(state.targets) - cursor = state.cursor - if cursor == CURSOR_DONE: - all_completed = all(name in state.completed_targets for name in state.targets) - # A terminal scope refusal is a permanent failure even though its - # target is "attempted": a checkpoint written between the last target - # and the final receipt must never persist COMPLETED for it. - clean = all_completed and not _has_terminal_scope_refusal(state.failures, state.targets) - status = OperationStatus.COMPLETED if clean else OperationStatus.FAILED - progress = 1.0 - completed_at: str | None = datetime.now(timezone.utc).isoformat() - else: - status = OperationStatus.RUNNING - processed = sum(name in state.completed_targets for name in state.targets) - progress = min(processed / total, 1.0) if total > 0 else 0.0 - completed_at = None - - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=state.targets, - status=status, - progress=progress, - started_at=started_at, - completed_at=completed_at, - affected_rows=state.repaired_total, - results=list(state.results), - scope=MaintenanceScope(targets=state.targets, filter=state.scope_filter), - resume_cursor=cursor, - failure_samples=BoundedFailureSamples( - samples=tuple(state.failures[:MAX_FAILURE_SAMPLES]), - truncated=state.failures_truncated or len(state.failures) > MAX_FAILURE_SAMPLES, - ), - metrics=_operation_metrics(state), - ) - - -__all__ = [ - "CURSOR_DONE", - "MAINTENANCE_TARGET_NAMES", - "MaintenanceScopeFilter", - "ProgressCallback", - "ReplayProgress", - "InvalidReplayStateError", - "IncompatibleReplayStateError", - "UnsupportedReplayTargetError", - "clear_state", - "execute_replay", - "load_state", - "rebuild_index_from_source", - "state_path_for", - "supported_replay_targets", -] diff --git a/polylogue/maintenance/sharded_rebuild.py b/polylogue/maintenance/sharded_rebuild.py deleted file mode 100644 index abb65a3474..0000000000 --- a/polylogue/maintenance/sharded_rebuild.py +++ /dev/null @@ -1,620 +0,0 @@ -"""Parallel sharded generation build for from-empty index rebuilds. - -polylogue-pzxm: the single-writer invariant -(``connection_profile.BULK_BUILD_WRITE_CONNECTION_PROFILE``'s -``locking_mode=EXCLUSIVE`` rationale) protects an *owned inactive* -generation with exactly one writer and zero readers until -:meth:`~polylogue.storage.index_generation.IndexGenerationStore.promote` -swaps the active symlink -- it is not load-bearing across *multiple* -owned-inactive generations built concurrently, each with its own single -writer. This module exploits that: split a rebuild pass's selected raw ids -into ``shard_count`` buckets, build each bucket into its own owned inactive -generation in parallel (:func:`_build_one_shard`), then fold every shard's -``index.db`` into the pass's real target generation with one sequential -ATTACH + ``INSERT OR REPLACE ... SELECT`` merge per table -(:func:`merge_shards_into_target`), and finally re-run cross-session graph -resolution over the merged generation (:func:`resolve_cross_shard_session_graph`) -so a parent/child pair split across two shards still composes exactly like a -single-writer replay would have produced. - -Non-goals (see polylogue-pzxm bead + lane brief): this module never touches -the promote()/lease machinery itself, the online (non-from-empty) single -writer contract, or ``sources/revision_backfill.py``'s replay-loop -internals -- it calls the same public -:func:`polylogue.maintenance.replay.rebuild_index_from_source` entry point -every non-sharded rebuild pass already uses, once per shard, against a -distinct owned inactive generation. - -Deferred surfaces are unaffected: ``bulk_build=True`` (passed to every shard -replay, matching the non-sharded rebuild caller) leaves ``messages_fts``, -``blocks_command_trigram``, ``action_pairs``, and ``delegation_facts`` empty -throughout -- exactly like a single-writer bulk-build pass -- so those -surfaces are never merged here; the existing archive-wide -``_repopulate_bulk_build_derived_state`` terminal stage in -``maintenance/rebuild_index.py`` repopulates them once, unchanged, from the -merged ``blocks``/``messages``/``session_links`` content after this module -returns. - -Measured honestly (``tests/benchmarks/test_sharded_rebuild.py``'s K=1/4/8 -sweep, synthetic corpus, see the polylogue-pzxm PR body for the exact -numbers): at 96-400 synthetic raws sharding was SLOWER than the single-writer -path, not faster -- K=4 measured 0.63-0.72x and K=8 measured 0.51-0.73x of -the K=1 baseline. Per-shard generation bootstrap (a full schema DDL replay -per shard), the merge's ``PRAGMA foreign_key_check``, and the post-merge -per-session graph-resolution pass are fixed overheads that do not shrink -with more shards, and this harness's synthetic payloads make the shardable -apply-phase work too small to amortize them. Whether the real archive's -apply-phase cost (graph_resolve alone measured ~156s real-run per the bead) -is large enough to cross over into a net win is NOT yet demonstrated by this -module -- it is the natural next measurement, not assumed here. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import hashlib -import sqlite3 -import time -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from polylogue.config import Config -from polylogue.logging import get_logger -from polylogue.paths import render_root -from polylogue.storage.index_generation import IndexGeneration -from polylogue.storage.sqlite.connection_profile import BULK_BUILD_WRITE_CONNECTION_PRAGMA_STATEMENTS - -if TYPE_CHECKING: - from polylogue.maintenance.rebuild_index import RebuildProvenanceContext - from polylogue.sources.revision_backfill import RawParsePrefetchCache - from polylogue.storage.index_generation import IndexGenerationStore - -logger = get_logger(__name__) - -#: Every table ``write_parsed_session_to_archive`` writes during a -#: ``bulk_build=True`` replay pass, in the order they must be merged so -#: FOREIGN KEY enforcement (deliberately disabled for the duration of the -#: merge, see :func:`merge_shards_into_target`) never needs to matter -- -#: order is cosmetic here, correctness comes from the post-merge -#: ``PRAGMA foreign_key_check``. Every one of these tables keys off -#: content-derived identity (``session_id``/``message_id``/``block_id`` -#: generated columns, or an explicit composite PRIMARY KEY) -- see -#: ``storage/sqlite/archive_tiers/index.py``'s ``CREATE TABLE`` statements -- -#: never a bare ``INTEGER PRIMARY KEY`` surrogate referenced elsewhere, so an -#: ``INSERT OR REPLACE ... SELECT`` merge across independently-built shards -#: is identity-safe: two shards that (via cohort-completeness expansion, -#: see ``replay.rebuild_index_from_source``'s docstring) both replay the same -#: raw converge on the same content-derived row, and REPLACE is a no-op. -#: -#: ``raw_revision_heads``/``raw_revision_applications`` are included even -#: though they hold cohort-authority bookkeeping rather than session -#: content, so a FUTURE incremental rebuild off the promoted generation -#: still sees every raw this pass applied as applied -- omitting them would -#: silently break ``--only-missing`` resumability for exactly the raws this -#: pass sharded. -MERGE_TABLES: tuple[str, ...] = ( - "raw_revision_heads", - "raw_revision_applications", - "sessions", - "messages", - "blocks", - "session_links", - "session_events", - "session_agent_policies", - "attachments", - "attachment_refs", - "attachment_native_ids", - "paste_spans", - "file_edits", - "session_working_dirs", - "session_refs", - "repos", - "repo_checkouts", - "session_repos", - "session_commits", - "session_model_usage", - "session_provider_usage_events", - "web_content_constructs", - "threads", - "thread_sessions", - "session_tags", -) - -#: The subset of :data:`MERGE_TABLES` the polylogue-pzxm correctness bar -#: names explicitly ("byte-identical schema SHA, per-table row counts, and -#: content SHA over ordered sessions/messages/blocks/session_links/ -#: action_pairs vs a sequential build"). ``action_pairs`` is deliberately -#: NOT in ``MERGE_TABLES`` (bulk_build leaves it empty per-shard, populated -#: only by the shared terminal repopulate stage) but IS part of the -#: equivalence surface, since it is derived from the merged content and -#: must still match byte-for-byte once that terminal stage runs. -EQUIVALENCE_TABLES: tuple[str, ...] = ("sessions", "messages", "blocks", "session_links", "action_pairs") - - -def _cohort_keys_for_raw_ids( - root: Path, raw_ids: list[str], *, prefetch_cache: RawParsePrefetchCache | None -) -> dict[str, str]: - """Map each raw id to the revision-cohort key it must stay grouped by. - - Two distinct cohort-crossing hazards exist, and only one has a cheap - pre-parse fix: - - - Byte-growth revision chains (older/head members of the same logical - source) are grouped by ``raw_sessions.logical_source_key``/ - ``source_path`` (``storage/sqlite/archive_tiers/source.py``) -- - exactly what ``classify_untyped_full_revision_groups`` - (revision_backfill.py, read only, not modified by this module) groups - candidates by before calling ``revision_replay.plan_revision_replay``. - Splitting a chain across two shards makes each shard's own census see - a lone candidate and ``plan_revision_replay`` raises (empirically - confirmed: "revision replay requires at least one candidate"). - ``source_path``/``logical_source_key`` are available from ``source.db`` - BEFORE any parse, since they are set at acquire/prior-classification - time. - - - Ambiguous-identity pairs (two raws whose PARSED content both claim the - same ``f"{origin}:{provider_session_id}"``) are grouped by that parsed - identity, computed fresh every pass inside ``_parse_retained_raws`` - (``membership_candidates``/``provisional_full_raw_ids`` in - revision_backfill.py) -- NOT available from ``source.db`` before a - parse on a from-empty rebuild's first pass, where - ``logical_source_key`` is still NULL for every raw. Splitting an - ambiguous pair across two shards makes each shard materialize its own - raw as an unambiguous session instead of correctly arbitrating the - conflict (empirically confirmed: sharded row counts exceeded - sequential row counts by exactly the split-pair count). - - ``prefetch_cache`` (the SAME ``RawParsePrefetchCache`` - ``_rebuild_index_from_source_owned`` already warms for every selected - raw id before this pass's replay -- see - ``rebuild_index.py::_warm_offline_prefetch_cache``) closes the second - gap for free: it was already going to parse every selected raw once - regardless of sharding, so reading its - :meth:`~RawParsePrefetchCache.peek_logical_keys` here costs nothing - extra and yields the REAL parsed identity, matching this bead's "threads - share parsed graphs" framing -- one shared parse feeds both cohort - partitioning and (via the same cache, still populated afterwards) each - shard's own replay. A raw id the cache does not cover (cache is ``None``, - admission-budget-rejected, or a multi-session bundle raw) falls back to - the ``source.db`` key, which is exact for chains but NOT for an - ambiguous pair split this way -- a known residual gap, see this - function's own docstring above and the polylogue-pzxm PR body. - """ - if not raw_ids: - return {} - keys: dict[str, str] = dict(prefetch_cache.peek_logical_keys()) if prefetch_cache is not None else {} - missing = [raw_id for raw_id in raw_ids if raw_id not in keys] - if missing: - source_db = root / "source.db" - if source_db.exists(): - placeholders = ",".join("?" for _ in missing) - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - rows = conn.execute( - f"SELECT raw_id, COALESCE(logical_source_key, source_path) FROM raw_sessions " - f"WHERE raw_id IN ({placeholders})", - missing, - ).fetchall() - keys.update({str(raw_id): str(cohort_key) for raw_id, cohort_key in rows}) - return keys - - -def shard_raw_ids( - root: Path, - raw_ids: list[str], - shard_count: int, - *, - prefetch_cache: RawParsePrefetchCache | None = None, -) -> list[list[str]]: - """Deterministically partition ``raw_ids`` into ``shard_count`` buckets. - - Partitions by revision-cohort key (see :func:`_cohort_keys_for_raw_ids`), - not bare ``raw_id`` -- every raw belonging to the same cohort is - guaranteed to land in the same bucket, so a bucket's shard-local replay - always sees a complete cohort, matching what a sequential replay of the - same raw ids would have seen. A raw id with no cohort evidence at all - (missing from both the prefetch cache and ``source.db``) falls back to - its own raw_id as a singleton cohort key. Hashing (not round-robin/ - contiguous slicing) so bucket membership does not depend on query row - order, and empty buckets are dropped by the caller rather than spun up as - a no-op shard build. - """ - if shard_count < 1: - raise ValueError("shard_count must be positive") - cohort_key_by_raw_id = _cohort_keys_for_raw_ids(root, raw_ids, prefetch_cache=prefetch_cache) - bucket_by_cohort_key: dict[str, int] = {} - buckets: list[list[str]] = [[] for _ in range(shard_count)] - for raw_id in raw_ids: - cohort_key = cohort_key_by_raw_id.get(raw_id, raw_id) - bucket = bucket_by_cohort_key.get(cohort_key) - if bucket is None: - digest = hashlib.sha256(cohort_key.encode("utf-8")).digest() - bucket = digest[0] % shard_count - bucket_by_cohort_key[cohort_key] = bucket - buckets[bucket].append(raw_id) - return buckets - - -@dataclass(frozen=True, slots=True) -class ShardBuildStats: - """Per-shard evidence folded into the aggregated receipt.""" - - generation_id: str - raw_count: int - replay: dict[str, object] - build_s: float - - -async def _build_one_shard( - *, - generation_store: IndexGenerationStore, - source_snapshot: str, - raw_ids: list[str], - raw_batch_size: int, - prefetch_cache: RawParsePrefetchCache | None, - provenance: RebuildProvenanceContext, - created_generations: list[IndexGeneration], -) -> tuple[IndexGeneration, ShardBuildStats]: - """Replay one shard's raw ids into its own owned inactive generation.""" - from polylogue.maintenance.replay import rebuild_index_from_source as replay_source - - provenance.validate() - generation = generation_store.create(source_snapshot=source_snapshot) - created_generations.append(generation) - provenance.validate() - generation_root = Path(generation.index_path).parent - config = Config( - archive_root=generation_root, - render_root=render_root(), - sources=[], - db_path=Path(generation.index_path), - ) - started_at = time.perf_counter() - replay = await replay_source( - config, - raw_ids=raw_ids, - raw_batch_size=raw_batch_size, - ingest_workers=None, - materialize=True, - progress_callback=None, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - bulk_fts=True, - bulk_build=True, - prefetch_cache=prefetch_cache, - deadline_check=provenance.validate, - ) - provenance.validate() - build_s = time.perf_counter() - started_at - return generation, ShardBuildStats( - generation_id=generation.generation_id, - raw_count=len(raw_ids), - replay=replay, - build_s=build_s, - ) - - -def _open_merge_connection(index_path: Path, *, timeout: int) -> sqlite3.Connection: - conn = sqlite3.connect(index_path, timeout=timeout) - try: - for statement in BULK_BUILD_WRITE_CONNECTION_PRAGMA_STATEMENTS: - if statement.strip().upper() == "PRAGMA FOREIGN_KEYS = ON": - # Merge deliberately runs with FK enforcement OFF: a shard's - # own sessions table can reference a parent that only exists - # in ANOTHER shard's sessions table, and the ATTACH+INSERT - # merge order across shards/tables is not guaranteed to place - # parents before children within one INSERT...SELECT (SQLite - # checks IMMEDIATE foreign keys per row, not per statement). - # `merge_shards_into_target` re-enables enforcement and runs - # `PRAGMA foreign_key_check` before returning, so this is a - # scoped, verified relaxation, not a weakened invariant. - conn.execute("PRAGMA foreign_keys = OFF") - continue - conn.execute(statement) - except BaseException: - conn.close() - raise - return conn - - -def _non_generated_columns(conn: sqlite3.Connection, table: str) -> list[str]: - """Column names to move in a merge, excluding GENERATED (STORED/VIRTUAL) columns. - - ``INSERT INTO t SELECT * FROM other.t`` is NOT a reliable way to skip - generated columns across an ATTACHed database (unlike a same-connection - self-select, it does not consistently apply SQLite's implicit-column-list - narrowing -- observed empirically: ``sessions`` has two generated columns - (``session_id``, ``sort_key_ms``) and a cross-attach ``SELECT *`` raised - ``table sessions has 38 columns but 40 values were supplied``). Building - an explicit column list from ``PRAGMA table_xinfo`` (``hidden`` 2=VIRTUAL, - 3=STORED) is correct regardless of that ATTACH-vs-self-select - inconsistency, and self-documents as schema changes add/remove generated - columns. - """ - return [str(row[1]) for row in conn.execute(f"PRAGMA table_xinfo({table})").fetchall() if int(row[6]) not in (2, 3)] - - -def merge_shards_into_target( - target_index_path: Path, - shard_index_paths: list[Path], - *, - provenance: RebuildProvenanceContext, -) -> dict[str, int]: - """ATTACH every shard's ``index.db`` and fold its rows into the target. - - Returns per-table total row counts written (across all shards, before - REPLACE de-duplication -- i.e. attempted rows, not necessarily the - target's final row count, which callers can read directly if needed). - Raises ``RuntimeError`` if the merged generation fails - ``PRAGMA foreign_key_check`` -- this is the load-bearing correctness - check for the FK-relaxed merge above. - """ - from polylogue.storage.fts.sql import FTS_BULK_SESSION_WRITE_GUARD - - row_counts: dict[str, int] = dict.fromkeys(MERGE_TABLES, 0) - aliases = [f"shard{i}" for i in range(len(shard_index_paths))] - provenance.validate() - with contextlib.closing(_open_merge_connection(target_index_path, timeout=600)) as conn: - conn.execute("PRAGMA busy_timeout = 600000") - attached: list[str] = [] - try: - # polylogue-pzxm: messages_fts/blocks_command_trigram's INSERT/ - # UPDATE/DELETE triggers on `blocks` are unconditional at the SQL - # level EXCEPT for this one guard row (`storage/fts/sql.py`'s - # `FTS_BULK_SESSION_WRITE_GUARD`, `NOT EXISTS (...)` in every - # trigger's WHEN clause) -- a normal per-session bulk_build write - # holds it only around that session's own block inserts. This - # merge inserts every shard's blocks in one connection with no - # such per-row scoping, so without holding the SAME guard for the - # whole merge, the target's triggers fire uncontrolled and leave - # messages_fts/blocks_command_trigram non-empty and inconsistent - # (empirically confirmed: merged messages_fts ended up with MORE - # rows than text-bearing blocks). Holding it here reproduces the - # exact invariant a sequential bulk_build replay already - # maintains (both derived stores stay empty until the shared - # terminal `_repopulate_bulk_build_derived_state` stage), so that - # stage's `resume_from_empty_message_index=True` precondition - # holds for the sharded path too. - provenance.validate() - conn.execute( - "INSERT OR IGNORE INTO derived_refresh_guard (guard_name) VALUES (?)", (FTS_BULK_SESSION_WRITE_GUARD,) - ) - for alias, shard_path in zip(aliases, shard_index_paths, strict=True): - provenance.validate() - conn.execute(f"ATTACH DATABASE ? AS {alias}", (str(shard_path),)) - attached.append(alias) - for table in MERGE_TABLES: - columns = _non_generated_columns(conn, table) - column_list = ", ".join(columns) - for alias in aliases: - provenance.validate() - cursor = conn.execute( - f"INSERT OR REPLACE INTO {table} ({column_list}) SELECT {column_list} FROM {alias}.{table}" - ) - row_counts[table] += cursor.rowcount if cursor.rowcount > 0 else 0 - provenance.validate() - provenance.validate() - conn.execute("DELETE FROM derived_refresh_guard WHERE guard_name = ?", (FTS_BULK_SESSION_WRITE_GUARD,)) - conn.commit() - violations = conn.execute("PRAGMA foreign_key_check").fetchall() - if violations: - raise RuntimeError( - f"sharded rebuild merge produced {len(violations)} foreign-key violations " - f"(first 10: {violations[:10]!r}); merged generation discarded by caller" - ) - finally: - for alias in attached: - try: - conn.execute(f"DETACH DATABASE {alias}") - except sqlite3.Error as exc: - raise RuntimeError(f"sharded rebuild failed to detach {alias}") from exc - conn.execute("PRAGMA foreign_keys = ON") - conn.commit() - return row_counts - - -def resolve_cross_shard_session_graph( - target_index_path: Path, - *, - provenance: RebuildProvenanceContext, -) -> float: - """Re-run per-session graph resolution over every merged session. - - Each shard's own replay already resolved links whose OTHER endpoint - existed within the same shard (via ``write.py``'s - ``_resolve_session_graph``, called per session at write time). A - parent/child pair split across two shards never had its counterpart - present during either shard's own replay, so ``session_links``/ - ``parent_session_id``/``root_session_id`` for that pair are still - unresolved after :func:`merge_shards_into_target`. Re-running resolution - for every session in the merged generation closes that gap; it is safe - (not merely convenient) to do so because ``_resolve_session_graph`` is - idempotent and fast-paths sessions whose projection is already current - (``_root_projection_current`` in write.py), so already-intra-shard- - resolved sessions cost only the fast-path check, not a full re-resolve. - - Returns the wall-clock seconds spent, folded into the aggregated - receipt's ``apply_s`` by the caller (this is single-writer, serialized - work -- it does not shard further). - """ - from polylogue.storage.sqlite.archive_tiers.write import _resolve_session_graph - - provenance.validate() - started_at = time.perf_counter() - with contextlib.closing(_open_merge_connection(target_index_path, timeout=600)) as conn: - conn.execute("PRAGMA busy_timeout = 600000") - conn.execute("PRAGMA foreign_keys = ON") - rows = conn.execute("SELECT session_id, native_id, origin FROM sessions ORDER BY session_id").fetchall() - signature_cache: dict[str, list[tuple[str, str]]] = {} - for session_id, native_id, origin in rows: - provenance.validate() - _resolve_session_graph( - conn, - session_id, - native_id, - origin, - cache=signature_cache, - add_timing=None, - bulk_fts=True, - bulk_build=True, - ) - provenance.validate() - conn.commit() - return time.perf_counter() - started_at - - -def _cleanup_shard_generations( - generation_store: IndexGenerationStore, - shard_generations: list[IndexGeneration], - provenance: RebuildProvenanceContext, -) -> list[BaseException]: - """Attempt every shard cleanup and return failures without short-circuiting.""" - errors: list[BaseException] = [] - for shard_generation in shard_generations: - try: - provenance.validate_cleanup() - discarded = generation_store.discard_if_inactive(shard_generation) - if not discarded: - raise RuntimeError(f"{shard_generation.generation_id} was not discarded") - except BaseException as exc: - cleanup_error = RuntimeError(f"{shard_generation.generation_id}: {exc}") - cleanup_error.__cause__ = exc - errors.append(cleanup_error) - return errors - - -def _surface_shard_cleanup_failures(primary: BaseException | None, cleanup_errors: list[BaseException]) -> None: - """Preserve a primary failure while surfacing every cleanup failure.""" - if not cleanup_errors: - return - detail = "; ".join(str(error) for error in cleanup_errors) - if primary is None: - raise RuntimeError(f"shard cleanup failed: {detail}") from cleanup_errors[0] - primary.add_note(f"shard cleanup also failed: {detail}") - - -async def replay_selected_raw_ids_sharded( - *, - root: Path, - generation_store: IndexGenerationStore, - generation: IndexGeneration, - selected_raw_ids: list[str], - raw_batch_size: int, - shard_count: int, - prefetch_cache: RawParsePrefetchCache | None, - provenance: RebuildProvenanceContext, -) -> dict[str, object]: - """Replay ``selected_raw_ids`` into ``generation`` via ``shard_count`` parallel shards. - - Drop-in replacement for a single ``replay.rebuild_index_from_source`` - call targeting the SAME already-created ``generation`` -- callers (see - ``maintenance/rebuild_index.py``) keep every terminal stage - (planner-statistics refresh, ``_repopulate_bulk_build_derived_state``, - FTS parity, readiness, promote) unchanged; this function's only - contract is that ``generation.index_path`` ends up holding exactly what - a sequential replay of ``selected_raw_ids`` would have written to the - tables in :data:`EQUIVALENCE_TABLES`, after its terminal stage runs. - - Shard generations are always discarded (``discard_if_inactive``) before - returning, success or failure -- they are scratch, never promotable. - """ - buckets = [ - bucket for bucket in shard_raw_ids(root, selected_raw_ids, shard_count, prefetch_cache=prefetch_cache) if bucket - ] - if not buckets: - return { - "scanned_raw_count": 0, - "classified_full_count": 0, - "replayed_logical_source_count": 0, - "quarantined_raw_count": 0, - "adoption_deferred_raw_count": 0, - "authority_selection_expanded": True, - "scheduled_raw_count": 0, - "raw_batch_size": raw_batch_size, - "ingest_workers": 0, - "parse_s": 0.0, - "apply_s": 0.0, - "stage_timings_s": {}, - "shard_count": 0, - } - logger.info( - "sharded_rebuild_build_start", - generation_id=generation.generation_id, - shard_count=len(buckets), - selected_raw_count=len(selected_raw_ids), - ) - created_generations: list[IndexGeneration] = [] - build_results = await asyncio.gather( - *[ - _build_one_shard( - generation_store=generation_store, - source_snapshot=generation.source_snapshot, - raw_ids=bucket, - raw_batch_size=raw_batch_size, - prefetch_cache=prefetch_cache, - provenance=provenance, - created_generations=created_generations, - ) - for bucket in buckets - ], - return_exceptions=True, - ) - failures = [result for result in build_results if isinstance(result, BaseException)] - if failures: - primary = failures[0] - cleanup_errors = _cleanup_shard_generations(generation_store, created_generations, provenance) - _surface_shard_cleanup_failures(primary, cleanup_errors) - raise primary - shard_generations = [cast(tuple[IndexGeneration, ShardBuildStats], result)[0] for result in build_results] - shard_stats = [cast(tuple[IndexGeneration, ShardBuildStats], result)[1] for result in build_results] - try: - merge_started_at = time.perf_counter() - row_counts = merge_shards_into_target( - Path(generation.index_path), [Path(sg.index_path) for sg in shard_generations], provenance=provenance - ) - merge_s = time.perf_counter() - merge_started_at - graph_resolve_s = resolve_cross_shard_session_graph(Path(generation.index_path), provenance=provenance) - except BaseException as primary: - cleanup_errors = _cleanup_shard_generations(generation_store, shard_generations, provenance) - _surface_shard_cleanup_failures(primary, cleanup_errors) - raise - else: - cleanup_errors = _cleanup_shard_generations(generation_store, shard_generations, provenance) - _surface_shard_cleanup_failures(None, cleanup_errors) - logger.info( - "sharded_rebuild_merge_complete", - generation_id=generation.generation_id, - shard_count=len(buckets), - merge_s=round(merge_s, 3), - graph_resolve_s=round(graph_resolve_s, 3), - row_counts=row_counts, - ) - parse_s = max((cast(float, s.replay.get("parse_s", 0.0)) for s in shard_stats), default=0.0) - shard_apply_s = max((cast(float, s.replay.get("apply_s", 0.0)) for s in shard_stats), default=0.0) - apply_s = shard_apply_s + merge_s + graph_resolve_s - stage_timings_s: dict[str, float] = {"shard.merge_s": merge_s, "shard.graph_resolve_s": graph_resolve_s} - for stats in shard_stats: - stage_timings_s[f"shard.{stats.generation_id}.build_s"] = stats.build_s - for key, value in cast(dict[str, float], stats.replay.get("stage_timings_s", {})).items(): - stage_timings_s[f"shard.{stats.generation_id}.{key}"] = value - ingest_workers = int(cast(int, shard_stats[0].replay.get("ingest_workers", 0))) if shard_stats else 0 - return { - "scanned_raw_count": sum(cast(int, s.replay.get("scanned_raw_count", 0)) for s in shard_stats), - "classified_full_count": sum(cast(int, s.replay.get("classified_full_count", 0)) for s in shard_stats), - "replayed_logical_source_count": sum( - cast(int, s.replay.get("replayed_logical_source_count", 0)) for s in shard_stats - ), - "quarantined_raw_count": sum(cast(int, s.replay.get("quarantined_raw_count", 0)) for s in shard_stats), - "adoption_deferred_raw_count": sum( - cast(int, s.replay.get("adoption_deferred_raw_count", 0)) for s in shard_stats - ), - "authority_selection_expanded": True, - "scheduled_raw_count": len(selected_raw_ids), - "raw_batch_size": raw_batch_size, - "ingest_workers": ingest_workers, - "parse_s": round(parse_s, 6), - "apply_s": round(apply_s, 6), - "stage_timings_s": {key: round(value, 6) for key, value in stage_timings_s.items()}, - "shard_count": len(buckets), - "shard_row_counts": row_counts, - } diff --git a/polylogue/readiness/__init__.py b/polylogue/readiness/__init__.py index cd905d4e04..887edc9582 100644 --- a/polylogue/readiness/__init__.py +++ b/polylogue/readiness/__init__.py @@ -84,7 +84,6 @@ class ReadinessReport(OutcomeReport): timestamp: int = field(default_factory=lambda: int(time.time())) derived_models: dict[str, DerivedModelStatus] = field(default_factory=dict) archive_debt: dict[str, ArchiveDebtStatus] = field(default_factory=dict) - active_rebuild_index_attempts: list[dict[str, object]] = field(default_factory=list) raw_materialization_readiness: dict[str, object] = field(default_factory=dict) raw_frontier_integrity: dict[str, object] = field(default_factory=dict) @@ -101,9 +100,7 @@ def provenance(self) -> _ReportProvenance: @property def archive_convergence(self) -> dict[str, object]: - archive_state_checked = bool( - self.raw_materialization_readiness or self.raw_frontier_integrity or self.active_rebuild_index_attempts - ) + archive_state_checked = bool(self.raw_materialization_readiness or self.raw_frontier_integrity) materialization_ready = raw_materialization_ready(self.raw_materialization_readiness) frontier_ready = ( not self.raw_frontier_integrity or self.raw_frontier_integrity.get("overall_status") == "healthy" @@ -118,11 +115,9 @@ def archive_convergence(self) -> dict[str, object]: } return { "checked": archive_state_checked, - "converging": archive_state_checked - and (bool(self.active_rebuild_index_attempts) or not materialization_ready or not frontier_ready), + "converging": archive_state_checked and (not materialization_ready or not frontier_ready), "materialization_ready": materialization_ready, "materialization_progress": materialization_progress, - "active_rebuild_index_attempts": self.active_rebuild_index_attempts, "raw_materialization_readiness": self.raw_materialization_readiness, "raw_frontier_integrity": self.raw_frontier_integrity, } @@ -662,13 +657,9 @@ def _raw_frontier_integrity_check(projection: RawFrontierIntegrityProjection) -> def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: bool = False) -> ReadinessReport: checks: list[ReadinessCheck] = [] - from polylogue.storage.archive_readiness import ( - active_rebuild_index_attempts, - raw_materialization_readiness_snapshot, - ) + from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot archive_root = _config_archive_root(config) - active_rebuild_attempts = active_rebuild_index_attempts(archive_root / "ops.db") raw_materialization_readiness = raw_materialization_readiness_snapshot(archive_root) raw_frontier_projection = raw_frontier_integrity_projection(archive_root, raw_materialization_readiness) raw_frontier_payload = raw_frontier_projection.to_dict() @@ -686,7 +677,6 @@ def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: boo checks.append(_skipped_index_check(db_error)) return ReadinessReport( checks=checks, - active_rebuild_index_attempts=active_rebuild_attempts, raw_materialization_readiness=raw_materialization_readiness, raw_frontier_integrity=raw_frontier_payload, ) @@ -745,7 +735,6 @@ def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: boo checks=checks, derived_models=derived_statuses, archive_debt=archive_debt, - active_rebuild_index_attempts=active_rebuild_attempts, raw_materialization_readiness=raw_materialization_readiness, raw_frontier_integrity=raw_frontier_payload, ) diff --git a/polylogue/readiness/claim_guard.py b/polylogue/readiness/claim_guard.py index 4c486099b2..2dda84c898 100644 --- a/polylogue/readiness/claim_guard.py +++ b/polylogue/readiness/claim_guard.py @@ -177,7 +177,7 @@ def derive_claim_guard( claim="perf_measurable", value=not active_writer, reason=perf_reason, - signal="live_ingest_attempts.running_count / active_rebuild_index_attempts", + signal="live_ingest_attempts.running_count", ) return ClaimGuard(openable=openable, converged=converged, search_ready=search, perf_measurable=perf) diff --git a/polylogue/sources/census_parse_stage.py b/polylogue/sources/census_parse_stage.py index 2b14a506a8..8c2657360c 100644 --- a/polylogue/sources/census_parse_stage.py +++ b/polylogue/sources/census_parse_stage.py @@ -1,23 +1,13 @@ """Shared off-writer-hold parse-stage engine: parse census candidates before any writer hold. -polylogue-m6tp phase (a), relocated to substrate (polylogue-czq2). Originally -lived in ``polylogue.daemon.parse_prefetch`` and was consulted by exactly one -caller (``daemon/bulk_rebuild.py``'s automagic bulk-rebuild routing) even -though the mechanism it provides -- pre-parsing a bounded set of raw ids in a -``ThreadPoolExecutor`` and handing the result to ``RawParsePrefetchCache`` -- -has nothing daemon-specific about it. Every OTHER caller of the shared -rebuild engine (the offline ``polylogue ops maintenance rebuild-index`` CLI, -and the daemon's own ``/api/maintenance/rebuild-index`` HTTP route) threaded -``prefetch_cache=None`` and paid the full serial re-parse/spill-reload cost -this module exists to avoid -- see ``maintenance/rebuild_index.py``'s -``_warm_offline_prefetch_cache`` for the fix that consumes this module -directly instead of only through the daemon's bulk-rebuild loop. +Pre-parses a bounded set of raw ids in a ``ThreadPoolExecutor`` and hands +the result to ``RawParsePrefetchCache`` so the writer-held materialization +pass never re-parses. Nothing here is daemon-specific; the daemon's +raw-materialization conveyor is the consumer. ``polylogue.daemon.parse_prefetch`` re-exports ``DaemonParseStage`` (an alias of :class:`CensusParseStage` below) and every config-resolution helper from -here unchanged, so every existing daemon caller/test keeps its import path -and behavior byte-identical; this module is the substrate the daemon -consumes, not a daemon-owned implementation detail any more. +here unchanged. The writer-hold contention this was originally built to avoid does not apply to the offline CLI or HTTP maintenance route the same way (there is no @@ -277,16 +267,11 @@ class CensusParseStage: """Owns a bounded pre-parse ``ThreadPoolExecutor`` and its prefetch cache. In the daemon, one instance lives for the process's lifetime (created - lazily on first use by the raw-materialization conveyor loop, or by - ``daemon/bulk_rebuild.py``'s bulk-rebuild routing). ``warm``/ + lazily on first use by the raw-materialization conveyor loop). ``warm``/ ``warm_raw_ids`` are synchronous/blocking -- a daemon caller runs them off the event loop (``asyncio.to_thread``), exactly like every other conveyor - pass, and NEVER under ``daemon_write_coordinator().run_sync``: doing so - would defeat the entire point, since the pre-parse must run without the - writer hold held. An offline caller (``maintenance/rebuild_index.py``) - instead constructs a short-lived instance scoped to one bounded pass's - raw ids and discards it once ``warm_raw_ids`` returns -- see - ``_warm_offline_prefetch_cache``. + pass, and NEVER under ``daemon_write_coordinator().run_sync``: the + pre-parse must run without the writer hold held. """ def __init__( @@ -333,10 +318,9 @@ def __init__( # keyed on the same raw_ids but accounting ESTIMATED PARSED-TREE # bytes instead of the raw cache's payload bytes. ``self.cache`` # itself is not touched/subclassed (it is a shared type consumed - # directly by other callers -- ``bulk_rebuild.py`` hands - # ``stage.cache`` straight to ``RebuildIndexRequest.prefetch_cache`` - # -- so this stays a side ledger that reconciles against the raw - # cache's own admission/eviction rather than replacing it. + # directly by other callers), so this stays a side ledger that + # reconciles against the raw cache's own admission/eviction rather + # than replacing it. self._max_cached_tree_bytes = ( max_cached_tree_bytes if max_cached_tree_bytes is not None else daemon_parse_stage_max_cached_tree_bytes() ) diff --git a/polylogue/sources/revision_backfill.py b/polylogue/sources/revision_backfill.py index bec62255d4..9b705a0ba2 100644 --- a/polylogue/sources/revision_backfill.py +++ b/polylogue/sources/revision_backfill.py @@ -820,7 +820,7 @@ class RawParsePrefetchCache: LRU-retained until evicted, so a raw whose bytes were already parsed on an EARLIER page of the SAME long-lived cache instance (the daemon's ``DaemonParseStage.cache`` is a process-lifetime singleton -- see - ``daemon/cli.py``'s ``_daemon_bulk_rebuild_parse_stage()``) is served + ``daemon/cli.py``'s ``_daemon_parse_stage()``) is served from cache on a LATER page instead of reparsed, closing the one real gap left by polylogue-869u's existing dedup (which only reuses a parse WITHIN one bounded ``_parse_retained_raws`` batch/page, never across the @@ -896,10 +896,9 @@ def pop(self, raw_id: str) -> tuple[list[ParsedSession], int, RawRevisionKind] | def peek_logical_keys(self) -> dict[str, str]: """Non-destructive ``raw_id -> "{origin}:{provider_session_id}"`` map. - polylogue-pzxm: the sharded from-empty rebuild - (``maintenance/sharded_rebuild.py``) partitions raw ids by revision- - cohort BEFORE any shard consumes this cache with ``pop``, using the - exact same logical-key derivation ``_parse_retained_raws`` uses when + Lets a caller partition raw ids by revision cohort BEFORE consuming + this cache with ``pop``, using the exact same logical-key derivation + ``_parse_retained_raws`` uses when it writes ``membership_candidates``/``provisional_full_raw_ids`` (``f"{origin_from_provider(session.source_name).value}:{session.provider_session_id}"``), so a byte-growth chain member or an ambiguous-identity pair never @@ -988,10 +987,9 @@ class RebuildDeadlineExceededError(RuntimeError): iteration -- i.e. *between* cohorts, never mid-cohort-apply. A cohort already durably committed (or accepted into the currently open, not-yet-committed replay batch) when this fires stands; the cohort about - to start does not begin. The caller (``maintenance/rebuild_index.py``) - catches this and checkpoints its resumable transaction WITHOUT advancing - the cursor, so the next pass re-derives from the exact same source-order - position -- safe by the existing content-hash idempotency invariant + to start does not begin. A caller that catches this and retries from the + same source-order position is safe by the existing content-hash + idempotency invariant (re-applying an already-committed cohort is a no-op upsert, never a duplicate), matching the crash-recovery contract an open replay batch already has (``test_backfill_resumes_after_replay_batch_crash_discards_ @@ -2236,8 +2234,7 @@ def backfill_historical_revision_evidence( ``bulk_fts`` (polylogue-crd8, default ``False``) is threaded to both ``apply_raw_revision_replay`` and ``apply_raw_membership_classification`` to enable the guard-gated bulk FTS mode for whale prefix-sharing lineage - cascades. Offline rebuild callers (``maintenance/rebuild_index.py`` via - ``maintenance/replay.py``) pass ``True``; other callers leave it off. + cascades. Ordinary convergence callers leave it off. ``bulk_build`` (polylogue-v6i3, default ``False``) mirrors ``bulk_fts``'s threading to the same two apply calls, enabling the broader @@ -3986,8 +3983,7 @@ def _declared_non_session_artifact_classification( intentional, so the retained bytes stay durable raw evidence. The live daemon's ingest path (``ingest_worker.py``/``batch.py``) already consults this same OriginSpec rule before parsing and refuses to session-parse - these; this replay engine (used by ``polylogue ops reset --index`` / - ``devtools`` rebuild-index) is a SEPARATE parse chokepoint that did not, + these; this replay engine is a SEPARATE parse chokepoint that did not, and would silently recreate exactly the ``.meta`` phantom sessions that fix is meant to eliminate on every future rebuild. A positive JSONL session proof is the one deliberate exception, matching the live route: @@ -4003,8 +3999,7 @@ def _declared_non_session_artifact_classification( path rule at all (e.g. a third-party analysis index such as ``conversation_relationships.jsonl`` that happens to satisfy the loose per-record shape check). Without the same content check here, replay - (this module) and rebuild (``maintenance/rebuild_index.py`` -> this - module) silently resurrect exactly the phantom sessions the live gate + (this module) would silently resurrect exactly the phantom sessions the live gate now refuses, on every future rebuild -- the two "single chokepoints" disagreeing is the location-as-identity defect recurring at a second layer. ``sample`` -- the first up to 64 decoded records, mirroring the diff --git a/polylogue/storage/archive_readiness.py b/polylogue/storage/archive_readiness.py index e11679d2ac..26ba0854e1 100644 --- a/polylogue/storage/archive_readiness.py +++ b/polylogue/storage/archive_readiness.py @@ -4,7 +4,6 @@ import json import sqlite3 -import time from collections import Counter from collections.abc import Mapping from contextlib import closing @@ -108,45 +107,6 @@ def probe_archive_tier(tier: ArchiveTier, path: Path) -> ArchiveTierProbe: convergence stage (daemon/convergence_stages.py); imported from there so the writer and this reader cannot drift apart.""" -ACTIVE_REBUILD_STALE_AFTER_S = 180.0 -"""Maximum heartbeat/start age for a rebuild-index row to count as active.""" - - -def active_rebuild_index_attempts(ops_db: Path) -> list[dict[str, object]]: - """Return active index-rebuild attempts recorded in the ops tier.""" - if not ops_db.exists(): - return [] - cutoff_ms = int((time.time() - ACTIVE_REBUILD_STALE_AFTER_S) * 1000) - try: - with closing(sqlite3.connect(f"file:{ops_db}?mode=ro", uri=True)) as conn: - conn.row_factory = sqlite3.Row - rows = conn.execute( - """ - SELECT attempt_id, phase, started_at_ms, heartbeat_at_ms, parsed_raw_count, materialized_count - FROM ingest_attempts - WHERE status = 'running' - AND phase = 'rebuild-index' - AND COALESCE(heartbeat_at_ms, started_at_ms) >= ? - ORDER BY started_at_ms DESC - LIMIT 8 - """, - (cutoff_ms,), - ).fetchall() - except sqlite3.Error as exc: - logger.warning("active rebuild-index attempts query failed for %s: %s", ops_db, exc, exc_info=True) - return [] - return [ - { - "attempt_id": str(row["attempt_id"]), - "phase": str(row["phase"]), - "started_at_ms": int(row["started_at_ms"]), - "heartbeat_at_ms": int(row["heartbeat_at_ms"]) if row["heartbeat_at_ms"] is not None else None, - "parsed_raw_count": int(row["parsed_raw_count"] or 0), - "materialized_count": int(row["materialized_count"] or 0), - } - for row in rows - ] - def claude_workflow_materialization_status(ops_db: Path) -> dict[str, object] | None: """Return the most recently recorded Claude Workflow materialization summary. @@ -1112,24 +1072,13 @@ def _raw_gap_parsed_non_session_artifact( # --------------------------------------------------------------------------- -# Archive readiness surfaces (polylogue-ogn1) +# Archive readiness surfaces # -# Extracted from ``polylogue/cli/commands/status.py``: the substrate module -# ``polylogue/maintenance/rebuild_index.py`` was importing a private -# CLI-surface helper (``_archive_readiness_status``) to check whether a freshly -# rebuilt generation is exact-ready before promotion. That is the inverse of -# this repo's documented layering rule ("surfaces may not import substrate -# internals directly", ``docs/plans/layering.yaml``) — here the substrate was -# reaching *up* into a CLI leaf adapter. This block gives both the CLI -# (`status.py`, human-facing readiness reporting) and the substrate -# (`rebuild_index.py`, promotion gating) a single shared home for the -# computation; the CLI now delegates to ``archive_readiness_status`` below -# instead of owning the only copy. The handful of tiny SQLite-introspection -# one-liners below (``_fast_count``/``_safe_int``/``_table_exists``/etc.) are -# intentionally duplicated from ``status.py``'s own private copies rather than -# migrated wholesale: those are used throughout the rest of ``status.py`` for -# unrelated status surfaces outside this cluster's scope, and a bulk -# utility-relocation refactor was not part of the layering fix being made. +# The substrate home for the exact-readiness computation; the CLI +# (``status.py``) delegates to ``archive_readiness_status`` below. The tiny +# SQLite-introspection one-liners (``_fast_count``/``_safe_int``/ +# ``_table_exists``/etc.) are duplicated from ``status.py``'s own private +# copies, which serve unrelated status surfaces. # --------------------------------------------------------------------------- @@ -1445,10 +1394,7 @@ def materialized(name: str) -> tuple[bool, list[str]]: def archive_readiness_status(root: Path) -> dict[str, Any]: """Return the exact-readiness surface report for one archive root. - Shared by the CLI's ``status``/``rebuild-index --plan`` reporting and the - substrate's ``rebuild_index_from_source`` promotion gate: a freshly - rebuilt generation is only promoted once every surface here reports - ``ready``. + Serves the CLI's ``status`` reporting. """ index_db = root / "index.db" source_db = root / "source.db" @@ -1521,8 +1467,6 @@ def archive_readiness_status(root: Path) -> dict[str, Any]: __all__ = [ - "ACTIVE_REBUILD_STALE_AFTER_S", - "active_rebuild_index_attempts", "archive_readiness_status", "missing_source_raw_session_evidence", "raw_materialization_readiness_snapshot", diff --git a/polylogue/storage/sqlite/action_pairs.py b/polylogue/storage/sqlite/action_pairs.py index abf727c7d7..c5cc2eebc2 100644 --- a/polylogue/storage/sqlite/action_pairs.py +++ b/polylogue/storage/sqlite/action_pairs.py @@ -136,8 +136,8 @@ def action_pairs_refresh_all_sql() -> str: def rebuild_all_action_pairs_sync(conn: sqlite3.Connection) -> None: """Repopulate ``action_pairs`` for every session in one bulk delete+insert. - The bulk-build readiness repopulate step (``maintenance/rebuild_index.py``) - calls this once after replay instead of relying on any per-session refresh. + A bulk repopulate calls this once after replay instead of relying on any + per-session refresh. """ conn.execute("DELETE FROM action_pairs") conn.execute(action_pairs_refresh_all_sql()) diff --git a/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py b/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py index 398d166494..dbcb4204e6 100644 --- a/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py +++ b/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py @@ -58,9 +58,8 @@ def should_skip_stale_replace( prefix copy of the same file the operator had uploaded into an AI Studio conversation -- without an accepted head, and this function's ordinary timestamp comparison (not content-subset awareness) decided the - outcome. Recomputing revision membership under current code (any - ``rebuild_index_from_source`` replay, which calls - ``backfill_historical_revision_evidence``) resolves that case correctly + outcome. Recomputing revision membership under current code + (``backfill_historical_revision_evidence``) resolves that case correctly upstream of this function. """ return ( diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index ae0bbc5ffd..4a22f7945b 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -492,8 +492,7 @@ def write_parsed_session_to_archive( bulk-generation-build lifecycle this session write may be part of: a full source-to-index replay that always finishes with exactly one archive-wide repopulate of ``messages_fts``/``blocks_command_trigram``/ - ``action_pairs``/``delegation_facts`` before readiness (see - ``maintenance/rebuild_index.py``). When ``True``, this write skips every + ``action_pairs``/``delegation_facts`` before readiness. When ``True``, this write skips every per-session refresh of those four derived surfaces entirely (not just the guard-gated bulk delete+insert ``bulk_fts`` performs) -- the final repopulate covers every session regardless, so per-session work here is diff --git a/polylogue/storage/sqlite/connection_profile.py b/polylogue/storage/sqlite/connection_profile.py index f78ba75a6a..56ea9fff82 100644 --- a/polylogue/storage/sqlite/connection_profile.py +++ b/polylogue/storage/sqlite/connection_profile.py @@ -187,11 +187,9 @@ def _scale_profile_size(default_size: int) -> int: journal_size_limit_bytes=WAL_JOURNAL_SIZE_LIMIT_BYTES, ) -# polylogue-623q: an owned INACTIVE index generation (bulk offline -# rebuild/backfill) is never read by anything until +# An owned INACTIVE index generation is never read by anything until # ``IndexGenerationStore.promote()`` swaps the ``index.db`` symlink, and is -# unconditionally discarded (``discard_if_inactive``) if the pass raises -- -# see ``maintenance/rebuild_index.py``'s ``_rebuild_index_from_source_owned``. +# unconditionally discarded (``discard_if_inactive``) if the pass raises. # That licenses a much more aggressive durability/speed tradeoff than the # live writer profile above, which must survive a crash mid-write against the # ONE active index a concurrent reader may be using right now: @@ -288,9 +286,7 @@ def mapped_bytes_budget(*, concurrent_read_connections: int = 4) -> int: """Plausible peak concurrent SQLite mmap+cache footprint for one polylogued process. Models the worst case that actually bit us: one bulk-build connection - (an offline `polylogue ops maintenance rebuild-index`, or a - daemon-triggered bulk rebuild via `daemon/bulk_rebuild.py`) running - concurrently with the daemon's own long-lived write connection + running concurrently with the daemon's own long-lived write connection (`DAEMON_WRITE_CONNECTION_PROFILE`) and a handful of concurrent short-lived read connections (CLI/MCP/API reads against the live archive while a rebuild is in flight), plus one ordinary writer, one diff --git a/polylogue/storage/sqlite/schema.py b/polylogue/storage/sqlite/schema.py index 6574ac6596..a5d97819fe 100644 --- a/polylogue/storage/sqlite/schema.py +++ b/polylogue/storage/sqlite/schema.py @@ -103,7 +103,7 @@ def assert_readable_archive_layout(conn: sqlite3.Connection, *, generation_id: s except RuntimeError as exc: raise SchemaVersionMismatchError( f"Archive index schema does not match runtime version {SCHEMA_VERSION}.{suffix} " - f"{exc} Rebuild the derived index from source with `polylogue ops maintenance rebuild-index`.", + f"{exc} Reset the derived index and let `polylogued run` rebuild it from source.", current_version=snapshot.current_version, expected_version=SCHEMA_VERSION, generation_id=generation_id, diff --git a/polylogue/storage/sqlite/schema_bootstrap.py b/polylogue/storage/sqlite/schema_bootstrap.py index ac2f5ec629..6503c3e792 100644 --- a/polylogue/storage/sqlite/schema_bootstrap.py +++ b/polylogue/storage/sqlite/schema_bootstrap.py @@ -59,7 +59,7 @@ def schema_version_mismatch_message(current_version: int, *, generation_id: str else: message = ( f"Database schema version {current_version} is not the expected archive version {SCHEMA_VERSION}. " - "Rebuild the derived index from source with `polylogue ops maintenance rebuild-index`." + "Reset the derived index and let `polylogued run` rebuild it from source." ) if generation_id is not None: action = "upgrade_runtime" if current_version > SCHEMA_VERSION else "rebuild_index" diff --git a/tests/benchmarks/test_rebuild_cost_model.py b/tests/benchmarks/test_rebuild_cost_model.py deleted file mode 100644 index 3f8b84ee27..0000000000 --- a/tests/benchmarks/test_rebuild_cost_model.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Stratified rebuild-cost model benchmark (polylogue-623q follow-up). - -This is not a micro-benchmark of one hot function -- it is the "predict a -full-corpus rebuild's wall-clock without running one" tool the operator asked -for. ``tests/infra/rebuild_cost_model.py`` carries the real logic (stratify -the raw population by origin x byte-size decile, synthesize a representative -sample per stratum, drive it through the REAL rebuild engine, extrapolate). - -The CI-safe test below exercises the model end to end against a small, -deterministic subset of the bundled population snapshot (fast: a handful of -tiny synthetic raws per stratum, not the full 41k-raw population) and asserts -the model machinery itself is sound (every stratum measured, no zero -denominators, wall-clock aggregates without error). - -To reproduce the full population projection and its calibration against the -one real measured rebuild (4h20m / 41,363 raws / 92.4 GiB), run: - - pytest tests/benchmarks/test_rebuild_cost_model.py::test_full_population_projection \\ - --benchmark-enable -p no:xdist -o "addopts=" -v -s --run-cost-model-full - -That variant is opt-in (skipped by default: it makes ~40 real rebuild passes --- two per stratum, for the fixed/marginal regression -- tens of seconds -each) and prints a stratum-by-stratum report plus the predicted/actual -calibration ratio. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import cast - -import pytest - -from tests.infra.rebuild_cost_model import ( - CALIBRATION_WALL_S, - POPULATION_SNAPSHOT, - REAL_RUN_STAGE_PROPORTIONS, - Stratum, - bucket_stage_seconds, - build_stratum_sample_corpus, - run_cost_model, - stage_proportions, -) - - -def test_default_sample_n_scales_inversely_with_size() -> None: - from polylogue.core.enums import Provider - from tests.infra.rebuild_cost_model import default_sample_n - - whale = Stratum("whale", Provider.CODEX, count=10_000, total_bytes=10_000 * 5_000_000) - tiny = Stratum("tiny", Provider.CODEX, count=10_000, total_bytes=10_000 * 500) - assert default_sample_n(whale) < default_sample_n(tiny) - assert default_sample_n(whale) >= 1 - assert default_sample_n(tiny) <= tiny.count - - -def test_real_run_stage_proportions_sum_to_one() -> None: - """Sanity floor on the ground-truth constant the fidelity table compares against.""" - assert sum(REAL_RUN_STAGE_PROPORTIONS.values()) == pytest.approx(1.0, abs=1e-6) - - -@pytest.mark.benchmark -def test_structural_corpus_exercises_cohort_arbitration(tmp_path: Path) -> None: - """The fidelity fix's core claim: a stratum with chain/ambiguous_fraction - set actually drives the REAL engine's cohort-arbitration machinery - (``replay.classify_cohort``/``membership.candidates`` etc.) -- dormant - with the pre-follow-up singleton-only corpus (polylogue-o56w). - """ - from polylogue.core.enums import Provider - from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - - stratum = Stratum( - "claude-code-small", - Provider.CLAUDE_CODE, - count=1000, - total_bytes=1000 * 4000, - chain_fraction=0.25, - ambiguous_fraction=0.25, - ) - archive_root = tmp_path / "structural-corpus" - raw_ids = build_stratum_sample_corpus(archive_root, stratum, sample_n=12) - # 12 * 0.25 = 3 -> 1 ambiguous pair (2 raws); pool=10, 10*0.25=2.5 -> round - # to 1 chain pair (2 raws); remaining 8 singles. Exact allocation is an - # implementation detail -- the structural assertion below is what matters. - assert len(raw_ids) == 12 - - import os - - prior = os.environ.get("POLYLOGUE_ARCHIVE_ROOT") - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = str(archive_root) - try: - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=archive_root, promote=True, raw_batch_size=12) - ) - finally: - if prior is None: - os.environ.pop("POLYLOGUE_ARCHIVE_ROOT", None) - else: - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = prior - - stage_timings_s = receipt.replay.get("stage_timings_s", {}) - assert isinstance(stage_timings_s, dict) - # The chain triggers the revision-replay cohort path; the ambiguous pair - # triggers membership arbitration. Both were structurally impossible to - # reach with a singleton-only corpus (every raw its own unambiguous - # first-time session). - assert "replay.classify_cohort" in stage_timings_s - assert any(key.startswith("membership.") for key in stage_timings_s) - # The ambiguous pair must genuinely fail to arbitrate a winner. - quarantined_raw_count = cast("int", receipt.replay.get("quarantined_raw_count", 0)) - assert quarantined_raw_count >= 2 - - bucketed = bucket_stage_seconds(receipt.replay, dict(receipt.timings_s)) - proportions = stage_proportions(bucketed) - # untimed_apply (replay.*/membership.* + everything not directly an - # index_parsed_write insert) must be non-zero once cohort arbitration - # actually ran -- this was structurally impossible (always exactly 0) - # before this follow-up. - assert proportions["untimed_apply"] > 0.0 - - -@pytest.mark.benchmark -def test_stratified_model_end_to_end_small(tmp_path: Path) -> None: - """CI-fast smoke test: the model machinery works on a tiny synthetic subset.""" - from polylogue.core.enums import Provider - - strata = [ - Stratum("codex-small", Provider.CODEX, count=6, total_bytes=6 * 20_000), - Stratum("claude-code-small", Provider.CLAUDE_CODE, count=6, total_bytes=6 * 20_000), - ] - predicted = run_cost_model(tmp_path, strata=strata, sample_sizes_override=(2, 4)) - assert len(predicted.measurements) == 2 - for measurement in predicted.measurements: - assert measurement.n1 == 2 - assert measurement.n2 == 4 - assert measurement.regression_valid - assert measurement.wall_s1 > 0.0 - assert measurement.wall_s2 > 0.0 - assert measurement.predicted_wall_s >= 0.0 - assert predicted.total_raws == 12 - assert predicted.total_predicted_wall_s >= 0.0 - - -@pytest.mark.benchmark -def test_full_population_projection(tmp_path: Path, request: pytest.FixtureRequest) -> None: - if not request.config.getoption("--run-cost-model-full", default=False): - pytest.skip("opt-in: pass --run-cost-model-full to measure ~20 real rebuild passes") - predicted = run_cost_model(tmp_path, strata=POPULATION_SNAPSHOT) - report = predicted.to_report() - print("\n" + report) - assert predicted.total_raws == sum(s.count for s in POPULATION_SNAPSHOT) - # Sanity floor: the model should land within an order of magnitude of the - # one real measured rebuild, not merely "some positive number". A wider - # miss means the model's assumptions (synthesized-payload shape, or the - # seconds-per-raw scaling itself) don't hold and should not be trusted - # for evaluating future changes -- see the module docstring's acceptance - # criterion. - ratio = predicted.calibration_ratio() - assert 0.1 < ratio < 10.0, ( - f"predicted/actual={ratio:.2f} is outside a sane order-of-magnitude band; " - f"predicted={predicted.total_predicted_wall_s / 60:.1f}min actual={CALIBRATION_WALL_S / 60:.1f}min" - ) - # Fidelity floor (polylogue-o56w follow-up): the apply-phase mix must at - # least SHOW cohort-arbitration cost, not be silently zero the way the - # pre-follow-up singleton-only corpus always was. This is deliberately a - # loose floor, not a tight tolerance -- see compare_to_real_run's - # docstring for why an exact percentage match isn't the bar. - apply_mix = predicted.population_stage_proportions - assert apply_mix["untimed_apply"] > 0.0, ( - "harness corpus produced zero untimed-apply (cohort classification/" - "membership arbitration) work -- it is not structurally representative " - "of the real rebuild's apply phase; see compare_to_real_run()" - ) diff --git a/tests/benchmarks/test_sharded_rebuild.py b/tests/benchmarks/test_sharded_rebuild.py deleted file mode 100644 index e2839ce66d..0000000000 --- a/tests/benchmarks/test_sharded_rebuild.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Sharded from-empty index rebuild: equivalence proof + K-sweep benchmark -(polylogue-pzxm). - -Two things live here, both driving the REAL rebuild engine end to end -(``rebuild_index_from_source_sync``, the same function the offline CLI and -daemon bulk-rebuild call -- no reimplementation), against the same -structurally-representative synthetic corpus -``tests/infra/rebuild_cost_model.py`` builds for the rebuild-cost harness -(polylogue-623q/o56w): - -- :func:`test_sharded_build_matches_sequential_build` -- the bead's - correctness bar: byte-identical schema SHA, per-table row counts, and - content SHA over ordered ``sessions``/``messages``/``blocks``/ - ``session_links``/``action_pairs`` between a ``shard_count=1`` (sequential) - and a ``shard_count=4`` (sharded) rebuild of the SAME source corpus -- - the PR #3469 "MANIFESTS IDENTICAL" pattern (see - ``tests/unit/sources/test_revision_backfill.py``'s - ``_index_content_manifest`` for the prior art this extends with hashing - and a wider table set). -- :func:`test_sharded_build_k_sweep_benchmark` -- an honest K=1/4/8 wall-clock - comparison on the harness fixture. This is NOT run against the live - archive (forbidden by the lane brief); it is a small, CI-fast synthetic - corpus, so the reported ratios are directional evidence for the shardable - portion of a rebuild pass, not a promise about the live 41k-raw archive. - -Both tests build the corpus ONCE via ``build_stratum_sample_corpus`` (fully -deterministic, see its docstring) and then physically copy the archive root -before each rebuild variant, so "same source" is a filesystem fact, not an -assumption about corpus-generation determinism holding across two calls. -""" - -from __future__ import annotations - -import hashlib -import os -import shutil -import sqlite3 -import time -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path - -import pytest - -from polylogue.core.enums import Provider -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from tests.infra.rebuild_cost_model import Stratum, build_stratum_sample_corpus - -#: The bead's named correctness surface, in composite-primary-key order so a -#: row-for-row comparison is well-defined without depending on SQLite's -#: physical row order (which ATTACH+INSERT merge does not preserve). -_EQUIVALENCE_TABLE_ORDER: dict[str, str] = { - "sessions": "session_id", - "messages": "message_id", - "blocks": "block_id", - "session_links": "src_session_id, dst_origin, dst_native_id, link_type", - "action_pairs": "session_id, tool_use_block_id", -} - - -#: Every table `polylogue.maintenance.sharded_rebuild.MERGE_TABLES` copies -#: across shards, checked for row-count parity (cheap, catches a merge -#: silently dropping or duplicating rows in a table content-hashing doesn't -#: cover). Mirrors that module's table list; imported by name below rather -#: than duplicated so a future table addition there is a single edit. -def _merge_table_names() -> tuple[str, ...]: - from polylogue.maintenance.sharded_rebuild import MERGE_TABLES - - return MERGE_TABLES - - -def _schema_sha(index_db: Path) -> str: - with sqlite3.connect(f"file:{index_db}?mode=ro", uri=True) as conn: - rows = conn.execute( - "SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name" - ).fetchall() - digest = hashlib.sha256() - for row in rows: - for value in row: - digest.update(str(value).encode("utf-8")) - digest.update(b"\0") - digest.update(b"\n") - return digest.hexdigest() - - -def _table_row_counts(index_db: Path, tables: tuple[str, ...]) -> dict[str, int]: - with sqlite3.connect(f"file:{index_db}?mode=ro", uri=True) as conn: - return {table: int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) for table in tables} - - -def _content_sha(index_db: Path) -> dict[str, str]: - """Per-table content SHA over ordered rows -- the PR #3469 currency, - extended from ``_index_content_manifest``'s plain-equality dict compare - to a hash so the fixture's row payload never has to live in this - process's memory at CI runner scale. - """ - digests: dict[str, str] = {} - with sqlite3.connect(f"file:{index_db}?mode=ro", uri=True) as conn: - for table, order in _EQUIVALENCE_TABLE_ORDER.items(): - digest = hashlib.sha256() - for row in conn.execute(f"SELECT * FROM {table} ORDER BY {order}"): - for value in row: - digest.update(repr(value).encode("utf-8")) - digest.update(b"\0") - digest.update(b"\n") - digests[table] = digest.hexdigest() - return digests - - -@contextmanager -def _archive_root_env(archive_root: Path) -> Iterator[None]: - prior = os.environ.get("POLYLOGUE_ARCHIVE_ROOT") - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = str(archive_root) - try: - yield - finally: - if prior is None: - os.environ.pop("POLYLOGUE_ARCHIVE_ROOT", None) - else: - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = prior - - -def _build_corpus(tmp_path: Path, *, sample_n: int) -> Path: - stratum = Stratum( - "claude-code-pzxm", - Provider.CLAUDE_CODE, - count=sample_n, - total_bytes=sample_n * 4000, - # Deliberately non-zero: a fork/chain member split across two - # shards by the hash partition is exactly the "hard part" the - # polylogue-pzxm bead calls out (cohort completeness/authority - # arbitration must not depend on which shard a raw landed in). - chain_fraction=0.25, - ambiguous_fraction=0.25, - ) - source_root = tmp_path / "source-corpus" - build_stratum_sample_corpus(source_root, stratum, sample_n=sample_n) - return source_root - - -def _rebuild(archive_root: Path, *, shard_count: int, raw_batch_size: int) -> float: - with _archive_root_env(archive_root): - started_at = time.perf_counter() - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=archive_root, - promote=True, - raw_batch_size=raw_batch_size, - shard_count=shard_count, - ) - ) - elapsed_s = time.perf_counter() - started_at - assert receipt.status == "replayed", receipt.to_dict() - return elapsed_s - - -@pytest.mark.benchmark -def test_sharded_build_matches_sequential_build(tmp_path: Path) -> None: - sample_n = 48 - source_root = _build_corpus(tmp_path, sample_n=sample_n) - - sequential_root = tmp_path / "sequential" - sharded_root = tmp_path / "sharded" - shutil.copytree(source_root, sequential_root) - shutil.copytree(source_root, sharded_root) - - _rebuild(sequential_root, shard_count=1, raw_batch_size=sample_n) - _rebuild(sharded_root, shard_count=4, raw_batch_size=sample_n) - - from polylogue.storage.archive_identity import ArchiveLocation - - sequential_index = ArchiveLocation.resolve(sequential_root).active_index_path - sharded_index = ArchiveLocation.resolve(sharded_root).active_index_path - - # 1. Byte-identical schema. - schema_sha_sequential = _schema_sha(sequential_index) - schema_sha_sharded = _schema_sha(sharded_index) - assert schema_sha_sharded == schema_sha_sequential - - # 2. Per-table row counts across every table the shard merge touches. - merge_tables = _merge_table_names() - counts_sequential = _table_row_counts(sequential_index, merge_tables) - counts_sharded = _table_row_counts(sharded_index, merge_tables) - assert counts_sharded == counts_sequential - # Sanity floor: the corpus actually produced content in the tables that - # matter, so an accidentally-empty merge could never pass this trivially. - assert counts_sequential["sessions"] > 0 - assert counts_sequential["messages"] > 0 - # Not session_links > 0: this corpus's chain_fraction/ambiguous_fraction - # produce revision-authority cohorts (byte-growth/ambiguous-identity - # conflicts within ONE logical session), not parent/child session forks - # -- session_links coverage for the cross-shard graph-resolution "hard - # part" lives in tests/unit/maintenance/test_sharded_rebuild.py instead, - # via a direct write_parsed_session_to_archive fork scenario. - - # 3. Content SHA over the bead-named equivalence tables (action_pairs - # included: empty per-shard under bulk_build, populated only by the - # shared terminal repopulate stage both paths run unchanged). - content_sha_sequential = _content_sha(sequential_index) - content_sha_sharded = _content_sha(sharded_index) - assert content_sha_sharded == content_sha_sequential - for table in _EQUIVALENCE_TABLE_ORDER: - assert content_sha_sharded[table] == content_sha_sequential[table], table - - -@pytest.mark.benchmark -def test_sharded_build_k_sweep_benchmark(tmp_path: Path) -> None: - """Honest K=1/4/8 wall-clock comparison on the harness fixture. - - No speedup threshold is asserted: the lane brief's instruction is to - report the measured numbers, including a smaller-than-projected win, not - to gate on a projection. See this test's printed report for the numbers - from the run that produced them. - """ - sample_n = 96 - source_root = _build_corpus(tmp_path, sample_n=sample_n) - - elapsed_by_k: dict[int, float] = {} - for k in (1, 4, 8): - root = tmp_path / f"k{k}" - shutil.copytree(source_root, root) - elapsed_by_k[k] = _rebuild(root, shard_count=k, raw_batch_size=sample_n) - - baseline = elapsed_by_k[1] - report_lines = [ - f"polylogue-pzxm K-sweep ({sample_n} synthetic raws, chain_fraction=0.25, ambiguous_fraction=0.25):", - ] - for k in (1, 4, 8): - speedup = baseline / elapsed_by_k[k] if elapsed_by_k[k] > 0 else float("nan") - report_lines.append(f" K={k}: {elapsed_by_k[k]:.3f}s (speedup vs K=1: {speedup:.2f}x)") - report = "\n".join(report_lines) - print("\n" + report) - - # Sanity floor only: every configuration must complete and produce a - # positive measurement -- this is the harness proving it engaged - # sharding at all (K=4/8 shard_count actually took the sharded_rebuild - # path -- see rebuild_index.py's dispatch on - # `shard_count > 1 and len(selected_raw_ids) >= shard_count`), not a - # performance gate. - assert all(elapsed > 0 for elapsed in elapsed_by_k.values()) diff --git a/tests/infra/rebuild_cost_model.py b/tests/infra/rebuild_cost_model.py deleted file mode 100644 index 5465314f56..0000000000 --- a/tests/infra/rebuild_cost_model.py +++ /dev/null @@ -1,1034 +0,0 @@ -"""Stratified rebuild-cost benchmark (polylogue-623q follow-up). - -The only way to know whether a rebuild-path change helped used to be running -a full multi-hour rebuild against the live archive. This module builds a -cheap substitute: stratify the raw population by (origin x byte-size decile), -synthesize a small representative sample per stratum, drive it through the -REAL rebuild engine (``polylogue.maintenance.rebuild_index. -rebuild_index_from_source_sync`` -- the same function the offline CLI and -daemon bulk-rebuild call, no reimplementation), measure seconds-per-raw at -each stratum's characteristic size, and extrapolate to the full population -using the population's real per-stratum counts. - -Two cost regimes exist in this archive's raw population and neither should be -assumed to dominate a priori: - -- byte-bound: a few thousand huge raws (multi-MB Codex rollouts) where cost - scales with payload bytes (parse/decode + writer throughput). -- count-bound: tens of thousands of small raws where FIXED per-raw overhead - (transaction bookkeeping, blob open, census receipt, index inserts) matters - more than their few KB of payload. - -Sampling at each stratum's OWN characteristic mean size and then scaling by -seconds-per-raw naturally blends both regimes without needing to fit which -one applies -- a stratum's measured seconds-per-raw already reflects -whatever mix of fixed and byte-proportional cost governs raws of that shape. - -``PopulationSnapshot`` below is a captured, aggregate-only (counts and byte -sums, never content) description of the live archive's raw population, -recorded because the live archive is not available to CI/cloud runs and -carries private content this public repo must never expose. Point -``collect_population_strata`` at a real ``source.db`` (read-only) to refresh -it from a live archive -- the snapshot embedded here does not need to be -re-derived to run the model; it is a fixed reference point analogous to -``PopulationSnapshot.captured_at``. -""" - -from __future__ import annotations - -import contextlib -import json -import os -import shutil -import sqlite3 -import time -import uuid -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field -from datetime import datetime, timezone -from pathlib import Path -from typing import cast - -from polylogue.core.enums import Provider -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - -# --------------------------------------------------------------------------- -# Population strata -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True, slots=True) -class Stratum: - """One (origin, byte-size decile) population bucket. - - ``chain_fraction`` and ``ambiguous_fraction`` (polylogue-o56w follow-up) - are the structural-mix corrections this module was missing: a stratum - built from ``count``/``total_bytes`` alone synthesizes only - "first-time, unambiguous" raws, which never exercises the real - engine's cohort-arbitration machinery (``replay.classify_cohort``, - ``replay.adoptable_check``, ``membership.candidates/project/classify``) - -- exactly the untimed-apply "dark matter" this follow-up targets. Both - default to ``0.0`` so existing hand-built ``Stratum`` instances (tests, - ad hoc strata) keep the prior singleton-only corpus shape. - - - ``chain_fraction``: fraction of the stratum's sample raws that belong - to a depth-2 byte-growth revision chain (one older, byte-prefix - member plus the head that supersedes it) instead of being a - standalone raw. Mirrors a session transcript re-acquired after it - grew (Claude Code/Codex resume). Drives - ``classify_untyped_full_revision_groups`` and the real - ``revision_replay`` cohort-replay path; the older member ends up - permanently ``RawRevisionAuthority.QUARANTINED`` by construction, - exactly like the live archive's non-head chain raws. - - ``ambiguous_fraction``: fraction of the stratum's sample raws - organized into duplicate-content pairs -- two raws sharing one - session identity with genuinely divergent (non-prefix) content, the - shape ``classify_membership_revisions`` cannot arbitrate. Drives the - ``membership_replay``/``membership.*`` stages and produces a - genuine, non-zero ``quarantined_raw_count`` (both members go - unclaimed), the same outcome a live duplicate/conflicting export - produces. - - Both fractions were measured against the live archive's - ``source.db`` (aggregate-only, read-only, no content -- see - ``_origin_structural_fractions``) and are DOCUMENTED APPROXIMATIONS, - not an exact replay of the live population's revision graph: real - chains range up to depth 16+ (this module always uses depth 2, the - modal case) and ``ambiguous_fraction`` is a residual estimate - (``revision_authority='quarantined' fraction - chain-involved - fraction``), not a directly measured ambiguous-arbitration rate -- - the live archive does not persist *why* a raw never got promoted. - """ - - label: str - provider: Provider - count: int - total_bytes: int - chain_fraction: float = 0.0 - ambiguous_fraction: float = 0.0 - - @property - def mean_bytes(self) -> int: - return max(1, round(self.total_bytes / self.count)) if self.count else 0 - - -def _origin_structural_fractions(conn: sqlite3.Connection, origin: str) -> tuple[float, float]: - """Read-only: (chain_fraction, ambiguous_fraction) for one origin. - - ``chain_fraction`` is the fraction of the origin's raws that sit in a - ``logical_source_key`` group with >=2 members (a proven byte-growth - revision chain) -- i.e. NOT the lone raw for its logical identity. - ``ambiguous_fraction`` is a residual: the fraction of raws whose - *final* persisted ``revision_authority`` is ``'quarantined'`` minus the - chain-involved fraction, floored at 0. ``raw_sessions.revision_authority`` - does not record *why* a raw was never promoted, so this treats - "quarantined but not in a multi-member chain" as the closest available - proxy for genuine cross-acquisition arbitration loss (duplicate/ - conflicting exports) -- see the ``Stratum`` docstring for the caveat. - Returns ``(0.0, 0.0)`` for an origin with no raws. - """ - total = int(conn.execute("SELECT COUNT(*) FROM raw_sessions WHERE origin = ?", (origin,)).fetchone()[0]) - if not total: - return 0.0, 0.0 - quarantined = int( - conn.execute( - "SELECT COUNT(*) FROM raw_sessions WHERE origin = ? AND revision_authority = 'quarantined'", (origin,) - ).fetchone()[0] - ) - group_sizes = [ - int(r[0]) - for r in conn.execute( - "SELECT COUNT(*) FROM raw_sessions WHERE origin = ? AND logical_source_key IS NOT NULL " - "GROUP BY logical_source_key", - (origin,), - ) - ] - chain_involved = sum(size for size in group_sizes if size >= 2) - chain_fraction = chain_involved / total - ambiguous_fraction = max(0.0, quarantined / total - chain_fraction) - return chain_fraction, ambiguous_fraction - - -def collect_population_strata( - source_db: Path, *, deciles_by_origin: Sequence[str] = ("codex-session", "claude-code-session") -) -> list[Stratum]: - """Read-only: stratify a live archive's raw_sessions by origin x byte-weighted decile. - - Never reads payload content -- only ``origin``/``blob_size`` aggregate - columns, plus (polylogue-o56w follow-up) the aggregate - ``logical_source_key``/``revision_authority`` columns used to derive each - origin's ``chain_fraction``/``ambiguous_fraction`` (see - ``_origin_structural_fractions`` -- still counts only, never raw - content). Origins listed in ``deciles_by_origin`` (the byte-dominant - ones) are split into 10 byte-weighted deciles each; every other origin is - pooled into one "long tail" stratum per origin group, since they are - small enough in aggregate bytes that within-origin size variance does not - materially change the wall-clock projection. The long-tail bucket's own - structural fractions are left at 0.0: those origins showed ~0 measured - chain involvement live (see module docstring), and their high raw - ``revision_authority='quarantined'`` rate could not be attributed to a - verified cost-bearing mechanism within this pass -- a documented gap, not - a silent omission. - """ - strata: list[Stratum] = [] - with contextlib.closing(sqlite3.connect(f"file:{source_db}?mode=ro", uri=True, timeout=10.0)) as conn: - origins = [str(r[0]) for r in conn.execute("SELECT DISTINCT origin FROM raw_sessions")] - tail_count = 0 - tail_bytes = 0 - for origin in origins: - provider = _provider_for_origin(origin) - sizes = [int(r[0]) for r in conn.execute("SELECT blob_size FROM raw_sessions WHERE origin = ?", (origin,))] - if not sizes: - continue - if origin not in deciles_by_origin: - tail_count += len(sizes) - tail_bytes += sum(sizes) - continue - chain_fraction, ambiguous_fraction = _origin_structural_fractions(conn, origin) - sizes.sort(reverse=True) - total = sum(sizes) - bucket_target = total / 10 if total else 0 - bucket_counts = [0] * 10 - bucket_bytes = [0] * 10 - acc = 0 - bi = 0 - for size in sizes: - acc += size - bucket_counts[bi] += 1 - bucket_bytes[bi] += size - if bucket_target and acc >= bucket_target * (bi + 1) and bi < 9: - bi += 1 - for decile, (count, nbytes) in enumerate(zip(bucket_counts, bucket_bytes, strict=True)): - if count == 0: - continue - strata.append( - Stratum( - label=f"{origin}/d{decile}", - provider=provider, - count=count, - total_bytes=nbytes, - chain_fraction=chain_fraction, - ambiguous_fraction=ambiguous_fraction, - ) - ) - if tail_count: - strata.append( - Stratum( - label="long-tail/other-origins", provider=Provider.CODEX, count=tail_count, total_bytes=tail_bytes - ) - ) - return strata - - -def _provider_for_origin(origin: str) -> Provider: - # Synthesis only supports origins with a validated realistic payload - # generator below (Codex, Claude Code). Every other origin is folded into - # the long-tail Codex-shaped stratum by collect_population_strata's - # deciles_by_origin default -- see module docstring's "documented - # simplification". - if origin == "claude-code-session": - return Provider.CLAUDE_CODE - return Provider.CODEX - - -#: Aggregate-only snapshot of the live archive's raw population, captured -#: 2026-07-30 via a read-only ``mode=ro`` connection (counts/bytes only, no -#: content -- see ``.claude/CLAUDE.md`` "repo is PUBLIC" constraint). Refresh -#: with ``collect_population_strata`` against a live ``source.db`` when the -#: archive shape has materially changed. -POPULATION_SNAPSHOT_CAPTURED_AT = datetime(2026, 7, 30, tzinfo=timezone.utc) - -#: Per-origin structural fractions (polylogue-o56w follow-up), captured -#: 2026-07-31 via the same read-only connection as the byte-size snapshot -#: above using ``_origin_structural_fractions`` -- see ``Stratum``'s -#: docstring for what each number means and its documented approximation. -#: ``codex-session``: chain_fraction=0.4668, ambiguous_fraction=0.1015. -#: ``claude-code-session``: chain_fraction=0.0909, ambiguous_fraction=0.0985. -#: long-tail origins measured ~0 chain involvement and their high raw -#: ``quarantined`` rate could not be attributed to a verified cost-bearing -#: mechanism in this pass -- left at 0.0/0.0, a documented gap (see module -#: docstring "Residual error"). -_CODEX_CHAIN_FRACTION = 0.4668 -_CODEX_AMBIGUOUS_FRACTION = 0.1015 -_CLAUDE_CODE_CHAIN_FRACTION = 0.0909 -_CLAUDE_CODE_AMBIGUOUS_FRACTION = 0.0985 - -POPULATION_SNAPSHOT: tuple[Stratum, ...] = ( - Stratum( - "codex-session/d0", - Provider.CODEX, - 16, - 7_201_200_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d1", - Provider.CODEX, - 19, - 7_344_300_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d2", - Provider.CODEX, - 24, - 6_929_000_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d3", - Provider.CODEX, - 38, - 7_139_300_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d4", - Provider.CODEX, - 49, - 7_178_300_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d5", - Provider.CODEX, - 111, - 7_060_600_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d6", - Provider.CODEX, - 234, - 7_128_400_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d7", - Provider.CODEX, - 428, - 7_146_500_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d8", - Provider.CODEX, - 715, - 7_131_400_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "codex-session/d9", - Provider.CODEX, - 7508, - 7_136_700_000, - chain_fraction=_CODEX_CHAIN_FRACTION, - ambiguous_fraction=_CODEX_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d0", - Provider.CLAUDE_CODE, - 5, - 2_266_100_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d1", - Provider.CLAUDE_CODE, - 32, - 2_073_200_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d2", - Provider.CLAUDE_CODE, - 51, - 2_111_600_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d3", - Provider.CLAUDE_CODE, - 93, - 2_155_900_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d4", - Provider.CLAUDE_CODE, - 139, - 2_136_200_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d5", - Provider.CLAUDE_CODE, - 205, - 2_150_000_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d6", - Provider.CLAUDE_CODE, - 339, - 2_147_700_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d7", - Provider.CLAUDE_CODE, - 745, - 2_145_500_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d8", - Provider.CLAUDE_CODE, - 2142, - 2_146_600_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - Stratum( - "claude-code-session/d9", - Provider.CLAUDE_CODE, - 15954, - 2_147_700_000, - chain_fraction=_CLAUDE_CODE_CHAIN_FRACTION, - ambiguous_fraction=_CLAUDE_CODE_AMBIGUOUS_FRACTION, - ), - # Long-tail origins (chatgpt-export, claude-ai-export, aistudio-drive, - # antigravity-session, hermes-session, ...): measured ~0 chain - # involvement live; see module-level structural-fraction note above for - # why ambiguous_fraction is left at 0.0 rather than guessed. - Stratum("long-tail/other-origins", Provider.CODEX, 12725, 6_439_959_524), -) - -#: The one real, measured full-corpus rebuild wall-clock -- the acceptance -#: criterion. A model that cannot reproduce this within a stated margin is -#: not trustworthy for evaluating future changes (operator directive). -CALIBRATION_WALL_S = 4 * 3600 + 20 * 60 # 4h20m -CALIBRATION_RAW_COUNT = 41_363 -CALIBRATION_TOTAL_BYTES = round(92.4 * 1024**3) - - -# --------------------------------------------------------------------------- -# Synthetic per-stratum corpus -# --------------------------------------------------------------------------- - -_ENVELOPE_OVERHEAD_BYTES = 220 - - -def _codex_payload(index: int, *, target_bytes: int, variant: str = "") -> bytes: - session_meta = ( - json.dumps( - {"type": "session_meta", "payload": {"id": f"cost-model-{index:06d}", "timestamp": "2026-06-01T00:00:00Z"}}, - separators=(",", ":"), - ) - + "\n" - ) - marker = f"variant-{variant}-" if variant else "" - text_len = max(1, target_bytes - len(session_meta) - _ENVELOPE_OVERHEAD_BYTES - len(marker)) - text = f"cost-model-payload-{index:06d}-{marker}" + ("x" * text_len) - response_item = ( - json.dumps( - { - "type": "response_item", - "payload": { - "type": "message", - "id": "one", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }, - }, - separators=(",", ":"), - ) - + "\n" - ) - return (session_meta + response_item).encode() - - -def _claude_code_payload(index: int, *, target_bytes: int, variant: str = "") -> bytes: - session_id = str(uuid.UUID(int=index, version=4)) - base_ts = 1_700_000_000.0 + index * 60 - marker = f"variant-{variant}-" if variant else "" - pad_len = max(1, target_bytes - 2 * _ENVELOPE_OVERHEAD_BYTES - len(marker)) - lines: list[str] = [] - for turn, role in enumerate(("user", "assistant")): - record = { - "type": role, - "uuid": str(uuid.uuid5(uuid.NAMESPACE_OID, f"{index}:{variant}:{turn}")), - "parentUuid": (str(uuid.uuid5(uuid.NAMESPACE_OID, f"{index}:{variant}:0")) if (index or turn) else None), - "sessionId": session_id, - "message": { - "role": role, - "content": [{"type": "text", "text": (marker + "x" * pad_len) if turn == 0 else "ack"}], - }, - "timestamp": datetime.fromtimestamp(base_ts + turn, tz=timezone.utc).isoformat(), - } - lines.append(json.dumps(record, separators=(",", ":"))) - return ("\n".join(lines) + "\n").encode() - - -def _payload_for_provider(provider: Provider, index: int, *, target_bytes: int, variant: str = "") -> bytes: - if provider is Provider.CLAUDE_CODE: - return _claude_code_payload(index, target_bytes=target_bytes, variant=variant) - return _codex_payload(index, target_bytes=target_bytes, variant=variant) - - -def _chain_member_payloads(provider: Provider, index: int, *, target_bytes: int) -> tuple[bytes, bytes]: - """(older, head) byte-growth chain pair -- older is a strict byte-prefix of head. - - Mirrors a growing session transcript file re-acquired across two - captures (Claude Code/Codex resume): the same physical shape - ``classify_untyped_full_revision_groups`` -> ``classify_historical_full_ - revision_streams`` proves as a ``byte_proven`` chain without ever - independently parsing the older member. The older member is deliberately - NOT required to be independently valid JSON/JSONL -- the real engine - never parses it either, exactly like a live truncated-mid-line partial - capture. - """ - head = _payload_for_provider(provider, index, target_bytes=target_bytes) - older_size = max(1, len(head) // 2) - return head[:older_size], head - - -def _ambiguous_pair_payloads(provider: Provider, index: int, *, target_bytes: int) -> tuple[bytes, bytes]: - """Two raws sharing one session identity with genuinely divergent content. - - Neither is a byte-prefix of the other, so ``classify_membership_ - revisions`` cannot arbitrate a winner -- both members go unclaimed - (``decision='ambiguous'``), exactly the shape a duplicate/conflicting - export of one session produces live. - """ - return ( - _payload_for_provider(provider, index, target_bytes=target_bytes, variant="a"), - _payload_for_provider(provider, index, target_bytes=target_bytes, variant="b"), - ) - - -def build_stratum_sample_corpus(archive_root: Path, stratum: Stratum, *, sample_n: int) -> list[str]: - """Write ``sample_n`` synthetic raws representative of ``stratum``. - - Fresh archive root; the caller owns cleanup. Every raw is still sized at - ``stratum.mean_bytes`` (or a byte-prefix of it, for an older chain - member), preserving the existing byte-size-driven timing behavior. On - top of that, ``stratum.ambiguous_fraction`` and ``stratum.chain_fraction`` - (polylogue-o56w follow-up) carve out a structurally representative - share of the sample into ambiguous-duplicate pairs and byte-growth - chains instead of every raw being a standalone, unambiguous first-time - session -- see ``Stratum``'s docstring for why this matters (it is what - makes ``replay.classify_cohort``/``replay.adoptable_check``/ - ``membership.candidates``/``membership.project``/``membership.classify`` - fire at all in this harness). Allocation order: ambiguous pairs first - (2 raws each), then chains (2 raws each) from what remains, then plain - singleton raws for the rest -- all deterministic, no randomness, so a - given ``(stratum, sample_n)`` always yields byte-identical corpora. - """ - initialize_active_archive_root(archive_root) - raw_ids: list[str] = [] - index = 0 - - def next_index() -> int: - nonlocal index - index += 1 - return index - - n_ambiguous_raws = 2 * (round(sample_n * stratum.ambiguous_fraction / 2) if stratum.ambiguous_fraction else 0) - n_ambiguous_raws = min(n_ambiguous_raws, sample_n - (sample_n % 2)) - pool = sample_n - n_ambiguous_raws - n_chain_raws = 2 * (round(pool * stratum.chain_fraction / 2) if stratum.chain_fraction else 0) - n_chain_raws = min(n_chain_raws, pool - (pool % 2)) - n_single_raws = pool - n_chain_raws - - with ArchiveStore.open_existing(archive_root, read_only=False) as archive: - for _ in range(n_ambiguous_raws // 2): - unit = next_index() - payload_a, payload_b = _ambiguous_pair_payloads(stratum.provider, unit, target_bytes=stratum.mean_bytes) - for suffix, payload in (("a", payload_a), ("b", payload_b)): - raw_ids.append( - archive.write_raw_payload( - provider=stratum.provider, - payload=payload, - source_path=f"cost-model/{stratum.label}/amb-{unit:06d}-{suffix}.jsonl", - acquired_at_ms=len(raw_ids) + 1, - ) - ) - for _ in range(n_chain_raws // 2): - unit = next_index() - older_payload, head_payload = _chain_member_payloads( - stratum.provider, unit, target_bytes=stratum.mean_bytes - ) - # Same source_path for both members -- classify_untyped_full_ - # revision_groups groups candidates by source_path (see its - # docstring); the older (smaller) acquisition must be written - # first so acquired_at_ms orders them chronologically. - source_path = f"cost-model/{stratum.label}/chain-{unit:06d}.jsonl" - raw_ids.append( - archive.write_raw_payload( - provider=stratum.provider, - payload=older_payload, - source_path=source_path, - acquired_at_ms=len(raw_ids) + 1, - ) - ) - raw_ids.append( - archive.write_raw_payload( - provider=stratum.provider, - payload=head_payload, - source_path=source_path, - acquired_at_ms=len(raw_ids) + 1, - ) - ) - for _ in range(n_single_raws): - unit = next_index() - payload = _payload_for_provider(stratum.provider, unit, target_bytes=stratum.mean_bytes) - raw_ids.append( - archive.write_raw_payload( - provider=stratum.provider, - payload=payload, - source_path=f"cost-model/{stratum.label}/{unit:06d}.jsonl", - acquired_at_ms=len(raw_ids) + 1, - ) - ) - return raw_ids - - -def default_sample_n(stratum: Stratum, *, target_sample_bytes: int = 2_000_000, min_n: int = 3, max_n: int = 40) -> int: - """More samples for small/count-bound strata, fewer for byte-bound whales.""" - if stratum.mean_bytes <= 0: - return min(stratum.count, min_n) - raw_target = max(min_n, round(target_sample_bytes / stratum.mean_bytes)) - return max(1, min(stratum.count, min(max_n, raw_target))) - - -# --------------------------------------------------------------------------- -# Stage-proportion fidelity (polylogue-o56w follow-up) -# --------------------------------------------------------------------------- -# -# The population-size/byte projection above (``predicted_wall_s``, -# ``calibration_ratio``) answers "how long will this take". It says nothing -# about WHETHER the harness is spending its time the way the real engine -# does -- a model that matches total wall-clock by getting one stage 3x too -# slow and another 3x too fast is not trustworthy for evaluating a change -# that touches only one of those stages. This section makes that mix -# checkable: it buckets a pass's ``stage_timings_s`` into the same four -# broad categories the real 4h22m rebuild's receipt decomposes into, and -# compares the harness's own proportions against that receipt's. - -#: The one real, measured full-corpus rebuild's stage decomposition -- the -#: fidelity acceptance criterion (operator directive: a harness is -#: trustworthy when its per-stage PROPORTIONS match this within a stated -#: tolerance, not when absolute times match). Computed directly from -#: ``/realm/db/polylogue/.index-rebuild-transactions/857984cb-b4cc-4537- -#: b0fb-eae89ca3fa96.receipts/pass-000000.json`` (private-archive path, not -#: readable from this public repo -- these are the derived aggregate -#: numbers only, no raw content): -#: -#: - ``total_wall_s`` = transaction.updated_at_ms - transaction.created_at_ms -#: = 15724.569s (4h22m04.6s, matches the operator's "4h22m" figure). -#: - ``parse_s`` = stage_timings_s["census"] + ["spill_load"] = 4032.184s. -#: - ``timed_apply_s`` = stage_timings_s["revision_replay.index_parsed_write"] -#: + ["membership_replay.index_parsed_write"] = 2453.585 + 979.514 = -#: 3433.099s -- the only apply-phase cost the PRE-#3469 harness could see -#: at all (its corpus never produced a membership_replay.* key). -#: - ``untimed_apply_s`` = apply_s - timed_apply_s = 8601.253 - 3433.099 = -#: 5168.154s -- the "33% dark matter" this follow-up targets: cohort -#: classification, membership candidate/project/classify, and the commit -#: storm PR #3469 already fixed. This receipt predates PR #3469's -#: ``replay.*``/``membership.*`` instrumentation, so none of it is -#: individually named here -- it is exactly the gap that instrumentation -#: now decomposes on the NEXT real rebuild (bead polylogue-o56w). -#: - ``terminal_s`` = total_wall_s - stage_timings_s["total"] = 15724.569 - -#: 12633.437 = 3091.132s -- post-pass repopulate/insights/readiness/ -#: promote, paid ONCE for the whole archive (not per-stratum -- see the -#: "Residual error" note on ``PredictedRun.compare_to_real_run``). -REAL_RUN_TOTAL_WALL_S = 15724.569 -REAL_RUN_STAGE_SECONDS: dict[str, float] = { - "parse": 4032.184306, - "timed_apply": 3433.098599, - "untimed_apply": 5168.154311, - "terminal": 3091.132, -} -REAL_RUN_STAGE_PROPORTIONS: dict[str, float] = { - name: seconds / REAL_RUN_TOTAL_WALL_S for name, seconds in REAL_RUN_STAGE_SECONDS.items() -} - -#: Order used everywhere a stage-proportion table is printed. -_STAGE_BUCKET_ORDER: tuple[str, ...] = ("parse", "timed_apply", "untimed_apply", "terminal") - - -def bucket_stage_seconds( - replay: Mapping[str, object], terminal_timings_s: Mapping[str, float] | None = None -) -> dict[str, float]: - """Bucket one pass's stage timings into (parse, timed_apply, untimed_apply, terminal). - - Mirrors exactly how ``REAL_RUN_STAGE_SECONDS`` above was derived from the - real receipt, so the two are directly comparable: - - - ``parse`` = ``parse_s`` (``census`` + ``spill_load``), read straight off - the replay dict (already computed by ``split_parse_and_apply_seconds``). - - ``timed_apply`` = sum of every ``*.index_parsed_write`` stage key -- - the only apply-phase cost a corpus with no revision chains or - membership cohorts can ever produce (pre-#3469-follow-up harness). - - ``untimed_apply`` = ``apply_s`` - ``timed_apply`` (never negative) -- - everything else charged against the pass's own wall-clock ``total``: - cohort classification, membership arbitration, census receipt commits. - - ``terminal`` = sum of a REBUILD RECEIPT's (not the backfill replay - dict's) ``timings_s`` -- the post-pass repopulate/insights/readiness/ - promote stages instrumented by PR #3469. ``0.0`` if not supplied. - """ - stage_timings_s = cast("Mapping[str, float]", replay.get("stage_timings_s", {})) - parse_s = float(cast("float", replay.get("parse_s", 0.0))) - apply_s = float(cast("float", replay.get("apply_s", 0.0))) - timed_apply_s = sum(v for k, v in stage_timings_s.items() if k.endswith(".index_parsed_write")) - untimed_apply_s = max(0.0, apply_s - timed_apply_s) - terminal_s = sum(terminal_timings_s.values()) if terminal_timings_s else 0.0 - return { - "parse": parse_s, - "timed_apply": timed_apply_s, - "untimed_apply": untimed_apply_s, - "terminal": terminal_s, - } - - -def stage_proportions(stage_seconds: Mapping[str, float]) -> dict[str, float]: - """Normalize a bucket-seconds dict into fractions of its own total.""" - total = sum(stage_seconds.get(name, 0.0) for name in _STAGE_BUCKET_ORDER) - if total <= 0: - return dict.fromkeys(_STAGE_BUCKET_ORDER, 0.0) - return {name: stage_seconds.get(name, 0.0) / total for name in _STAGE_BUCKET_ORDER} - - -def stage_proportion_table( - measured: Mapping[str, float], reference: Mapping[str, float] = REAL_RUN_STAGE_PROPORTIONS -) -> str: - """Render a measured-vs-real-run proportion table, one row per bucket.""" - lines = [f"{'stage':14s} {'measured':>10s} {'real_run':>10s} {'delta_pp':>9s}"] - for name in _STAGE_BUCKET_ORDER: - m = measured.get(name, 0.0) - r = reference.get(name, 0.0) - lines.append(f"{name:14s} {m * 100:9.1f}% {r * 100:9.1f}% {(m - r) * 100:+8.1f}pp") - return "\n".join(lines) - - -@dataclass(frozen=True, slots=True) -class StratumMeasurement: - """Two-point regression result for one stratum. - - A single-sample measurement conflates two different costs: the FIXED - one-time overhead ``rebuild_index_from_source_sync`` pays per PASS - (generation bootstrap, embeddings/FTS bulk-repopulate, promote, - readiness checks -- paid once no matter how many raws the pass - replays) and the MARGINAL cost per raw actually replayed. Dividing a - single sample's wall-clock by its (small) sample_n and multiplying by - the population count multiplies the fixed cost too, which the real - full rebuild pays only ONCE across the whole population -- this was - the source of a measured 1.78x over-prediction in the first version of - this model (see polylogue-623q notes). - - Fitting two points (``n1`` < ``n2`` raws, same stratum, fresh archive - each) separates the two: ``marginal_s_per_raw`` is the slope - ``(wall_s2 - wall_s1) / (n2 - n1)``; ``fixed_s`` is the intercept. - Population extrapolation then multiplies ONLY the marginal term - (``predicted_wall_s``) -- ``fixed_s`` is reported for transparency but - deliberately not added into any per-stratum or population total: the - real full rebuild runs as ONE pass, so its one-time fixed cost should - be counted once across the whole run, not once per stratum sample (and - this model does not attempt that separate accounting -- see the - module-level report footer). - """ - - stratum: Stratum - n1: int - n2: int - wall_s1: float - wall_s2: float - fixed_s: float - marginal_s_per_raw: float - sample_bytes: int - parse_s: float - apply_s: float - regression_valid: bool - #: Stage-bucket seconds (``bucket_stage_seconds``) from the LARGER - #: sample's pass (``n2``, or ``n1`` when the regression couldn't split) - #: -- used for the stage-proportion fidelity table, never for the - #: wall-clock population projection above. - stage_seconds: dict[str, float] = field(default_factory=dict) - - @property - def predicted_wall_s(self) -> float: - """Population extrapolation using ONLY the marginal per-raw term.""" - return max(0.0, self.marginal_s_per_raw) * self.stratum.count - - -def _run_one_rebuild_pass( - archive_root: Path, stratum: Stratum, n: int -) -> tuple[float, dict[str, object], dict[str, float]]: - build_stratum_sample_corpus(archive_root, stratum, sample_n=n) - prior_env = os.environ.get("POLYLOGUE_ARCHIVE_ROOT") - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = str(archive_root) - try: - started = time.perf_counter() - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=archive_root, promote=True, raw_batch_size=max(n, 1)) - ) - wall_s = time.perf_counter() - started - finally: - if prior_env is None: - os.environ.pop("POLYLOGUE_ARCHIVE_ROOT", None) - else: - os.environ["POLYLOGUE_ARCHIVE_ROOT"] = prior_env - assert receipt.status == "replayed", f"stratum {stratum.label}: unexpected receipt status {receipt.status!r}" - return wall_s, receipt.replay, dict(receipt.timings_s) - - -def two_point_sample_sizes(stratum: Stratum) -> tuple[int, int]: - """(n1, n2) sample sizes for the regression -- n2 > n1 when population allows.""" - n1 = default_sample_n(stratum) - n2 = min(stratum.count, max(n1 + 3, n1 * 4)) - return n1, n2 - - -def measure_stratum( - archive_root: Path, stratum: Stratum, *, sample_sizes: tuple[int, int] | None = None -) -> StratumMeasurement: - """Two-point regression: replay the same stratum at two sample sizes to - separate fixed per-pass overhead from marginal per-raw cost. - - Each sample gets a fresh scratch archive (``archive_root / "n1"`` / - ``"n2"``) so neither pass's index/generation state leaks into the other. - """ - n1, n2 = sample_sizes if sample_sizes is not None else two_point_sample_sizes(stratum) - wall_s1, replay1, terminal1 = _run_one_rebuild_pass(archive_root / "n1", stratum, n1) - regression_valid = n2 > n1 - if regression_valid: - wall_s2, replay2, terminal2 = _run_one_rebuild_pass(archive_root / "n2", stratum, n2) - marginal_s_per_raw = (wall_s2 - wall_s1) / (n2 - n1) - fixed_s = wall_s1 - marginal_s_per_raw * n1 - replay, terminal = replay2, terminal2 - else: - # Population too small to split (n2 == n1): fall back to treating the - # whole single-sample wall-clock as marginal cost, matching the prior - # (known-biased) behavior for this stratum only. These strata are a - # tiny fraction of the population by construction (see - # POPULATION_SNAPSHOT) so the bias this reintroduces is bounded. - wall_s2 = wall_s1 - marginal_s_per_raw = wall_s1 / n1 if n1 else 0.0 - fixed_s = 0.0 - replay, terminal = replay1, terminal1 - - parse_s = float(cast("float", replay.get("parse_s", 0.0))) - apply_s = float(cast("float", replay.get("apply_s", 0.0))) - return StratumMeasurement( - stratum=stratum, - n1=n1, - n2=n2, - wall_s1=wall_s1, - wall_s2=wall_s2, - fixed_s=fixed_s, - marginal_s_per_raw=marginal_s_per_raw, - sample_bytes=n2 * stratum.mean_bytes, - parse_s=parse_s, - apply_s=apply_s, - regression_valid=regression_valid, - stage_seconds=bucket_stage_seconds(replay, terminal), - ) - - -@dataclass(slots=True) -class PredictedRun: - measurements: list[StratumMeasurement] = field(default_factory=list) - - @property - def total_predicted_wall_s(self) -> float: - return sum(m.predicted_wall_s for m in self.measurements) - - @property - def total_raws(self) -> int: - return sum(m.stratum.count for m in self.measurements) - - @property - def total_bytes(self) -> int: - return sum(m.stratum.total_bytes for m in self.measurements) - - def calibration_ratio(self, calibration_wall_s: float = CALIBRATION_WALL_S) -> float: - """predicted / actual -- 1.0 is a perfect match, >1 over-predicts.""" - return self.total_predicted_wall_s / calibration_wall_s if calibration_wall_s else float("nan") - - @property - def total_fixed_s_measured(self) -> list[float]: - """Every stratum's measured one-time pass overhead (not summed into - any total -- the real full rebuild pays this ONCE, not once per - stratum; see StratumMeasurement's docstring).""" - return [m.fixed_s for m in self.measurements if m.regression_valid] - - @property - def population_stage_proportions(self) -> dict[str, float]: - """Population-count-weighted average of every stratum's OWN stage mix. - - Each stratum's measured pass already reports what fraction of ITS - OWN wall-clock went to parse/timed_apply/untimed_apply/terminal - (``stage_proportions(m.stage_seconds)``); this weights those - per-stratum mixes by the stratum's real population share - (``stratum.count / total_raws``) to estimate the mix a full rebuild - would show, without re-deriving absolute seconds (which the - two-point fixed/marginal split already handles separately). See - ``compare_to_real_run`` for why ``terminal`` specifically must be - read as a HARNESS ARCHITECTURE artifact, not a fidelity number. - """ - total_count = self.total_raws - if not total_count: - return dict.fromkeys(_STAGE_BUCKET_ORDER, 0.0) - weighted: dict[str, float] = dict.fromkeys(_STAGE_BUCKET_ORDER, 0.0) - for m in self.measurements: - weight = m.stratum.count / total_count - for name, fraction in stage_proportions(m.stage_seconds).items(): - weighted[name] += fraction * weight - return weighted - - def compare_to_real_run(self) -> str: - """The fidelity deliverable: measured stage mix vs. the real 4h22m run's. - - Two tables: the full 4-bucket mix (comparable to - ``REAL_RUN_STAGE_PROPORTIONS`` as-is) and an apply-phase-only mix - (parse/timed_apply/untimed_apply renormalized to 100%, terminal - excluded). The apply-only table is the one that actually answers - "is the harness representative of the 33% dark-matter gap" -- - ``terminal`` is structurally incomparable 1:1: every stratum pass - here pays the FULL terminal-stage cost (repopulate/insights/ - readiness/promote run at whatever scale this pass's tiny index.db - is), while the real archive pays it exactly ONCE across the whole - 41k-raw population. A multi-stratum harness that runs N separate - passes cannot reproduce a cost that is by design paid once per - FULL rebuild -- inflating ``terminal``'s measured share here is - expected harness architecture, not something a corpus change can - fix. That is this model's stated residual error on ``terminal``; - the parse/timed_apply/untimed_apply mix is the part this follow-up - claims fidelity on. - """ - measured = self.population_stage_proportions - full_table = stage_proportion_table(measured) - apply_only_names = ("parse", "timed_apply", "untimed_apply") - measured_apply_total = sum(measured.get(name, 0.0) for name in apply_only_names) - real_apply_total = sum(REAL_RUN_STAGE_PROPORTIONS.get(name, 0.0) for name in apply_only_names) - measured_apply_only = ( - {name: measured.get(name, 0.0) / measured_apply_total for name in apply_only_names} - if measured_apply_total > 0 - else dict.fromkeys(apply_only_names, 0.0) - ) - real_apply_only = { - name: REAL_RUN_STAGE_PROPORTIONS.get(name, 0.0) / real_apply_total for name in apply_only_names - } - apply_lines = [f"{'stage':14s} {'measured':>10s} {'real_run':>10s} {'delta_pp':>9s}"] - for name in apply_only_names: - m = measured_apply_only.get(name, 0.0) - r = real_apply_only.get(name, 0.0) - apply_lines.append(f"{name:14s} {m * 100:9.1f}% {r * 100:9.1f}% {(m - r) * 100:+8.1f}pp") - return ( - "full mix (parse/timed_apply/untimed_apply/terminal, population-weighted):\n" - f"{full_table}\n\n" - "apply-phase-only mix (terminal excluded, renormalized -- the fidelity claim this follow-up makes):\n" - f"{chr(10).join(apply_lines)}\n\n" - "residual error: terminal is structurally over-represented per-stratum-pass " - "(each pass pays it once for its own tiny index.db; the real run pays it once " - "for the whole archive) -- not comparable via this table; see " - "compare_to_real_run's docstring." - ) - - def to_report(self) -> str: - lines = [ - f"{'stratum':28s} {'n_pop':>8s} {'n1':>4s} {'n2':>4s} {'fixed_s':>8s} {'marg_s/raw':>11s} {'pred_min':>9s}", - ] - for m in sorted(self.measurements, key=lambda m: -m.predicted_wall_s): - flag = "" if m.regression_valid else "*" - lines.append( - f"{m.stratum.label:28s} {m.stratum.count:8d} {m.n1:4d} {m.n2:4d} " - f"{m.fixed_s:8.3f} {m.marginal_s_per_raw:11.4f} {m.predicted_wall_s / 60:9.2f}{flag}" - ) - total_min = self.total_predicted_wall_s / 60 - lines.append( - f"\npredicted total (marginal term only): {total_min:.1f} min over " - f"{self.total_raws} raws, {self.total_bytes / 1024**3:.2f} GiB" - ) - fixed_values = self.total_fixed_s_measured - if fixed_values: - lines.append( - f"measured one-time per-pass fixed overhead across strata: " - f"min={min(fixed_values):.2f}s max={max(fixed_values):.2f}s " - f"median={sorted(fixed_values)[len(fixed_values) // 2]:.2f}s " - "-- NOT added into the predicted total (the real run pays this once, " - "not once per stratum sample; see module docstring)." - ) - starred = [m.stratum.label for m in self.measurements if not m.regression_valid] - if starred: - lines.append(f"* regression not possible (population too small to split): {', '.join(starred)}") - lines.append( - f"calibration: actual={CALIBRATION_WALL_S / 60:.1f} min " - f"({CALIBRATION_RAW_COUNT} raws, {CALIBRATION_TOTAL_BYTES / 1024**3:.2f} GiB) " - f"-> ratio(predicted/actual)={self.calibration_ratio():.2f}" - ) - lines.append("\nstage-proportion fidelity (polylogue-o56w follow-up):") - lines.append(self.compare_to_real_run()) - return "\n".join(lines) - - -def run_cost_model( - workdir: Path, - strata: Sequence[Stratum] = POPULATION_SNAPSHOT, - *, - sample_sizes_override: tuple[int, int] | None = None, -) -> PredictedRun: - """Measure every stratum (each gets a fresh scratch archive) and extrapolate.""" - predicted = PredictedRun() - for i, stratum in enumerate(strata): - stratum_root = workdir / f"stratum-{i:03d}" - try: - measurement = measure_stratum(stratum_root, stratum, sample_sizes=sample_sizes_override) - predicted.measurements.append(measurement) - finally: - shutil.rmtree(stratum_root, ignore_errors=True) - return predicted - - -__all__ = [ - "CALIBRATION_RAW_COUNT", - "CALIBRATION_TOTAL_BYTES", - "CALIBRATION_WALL_S", - "POPULATION_SNAPSHOT", - "POPULATION_SNAPSHOT_CAPTURED_AT", - "REAL_RUN_STAGE_PROPORTIONS", - "REAL_RUN_STAGE_SECONDS", - "REAL_RUN_TOTAL_WALL_S", - "PredictedRun", - "Stratum", - "StratumMeasurement", - "bucket_stage_seconds", - "build_stratum_sample_corpus", - "collect_population_strata", - "default_sample_n", - "measure_stratum", - "run_cost_model", - "stage_proportion_table", - "stage_proportions", - "two_point_sample_sizes", -] diff --git a/tests/unit/cli/test_maintenance_operation_id_boundaries.py b/tests/unit/cli/test_maintenance_operation_id_boundaries.py deleted file mode 100644 index a05d321485..0000000000 --- a/tests/unit/cli/test_maintenance_operation_id_boundaries.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Fail-closed operation-ID validation at maintenance Click boundaries.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from polylogue.cli.commands.maintenance import _raw_authority_recovery as raw_module -from polylogue.cli.commands.maintenance import _rebuild_index_status as rebuild_module -from polylogue.cli.commands.maintenance import _status as status_module -from polylogue.cli.shared.types import AppEnv - -INVALID_OPERATION_ID = "../not-an-opaque-id" - - -def test_status_rejects_invalid_operation_id_before_registry_lookup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(status_module, "archive_root", lambda: tmp_path) - - def fail_lookup(*args: object, **kwargs: object) -> object: - raise AssertionError("registry lookup must not run for invalid operation IDs") - - from polylogue.maintenance import registry - - monkeypatch.setattr(registry.MaintenanceOperationRegistry, "get_operation", fail_lookup) - result = CliRunner().invoke( - status_module.status_command, - ["--operation-id", INVALID_OPERATION_ID], - obj=AppEnv(), - ) - - assert result.exit_code != 0 - assert "operation_id must not contain path separators" in result.output - - -def test_rebuild_index_status_rejects_invalid_operation_id_before_status_lookup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr(rebuild_module, "archive_root", lambda: tmp_path) - - def fail_status(*args: object, **kwargs: object) -> object: - raise AssertionError("status lookup must not run for invalid operation IDs") - - from polylogue.maintenance import rebuild_index - - monkeypatch.setattr(rebuild_index, "rebuild_status", fail_status) - result = CliRunner().invoke( - rebuild_module.rebuild_index_status_command, - ["--operation-id", INVALID_OPERATION_ID], - ) - - assert result.exit_code != 0 - assert "operation_id must not contain path separators" in result.output - - -def test_raw_authority_recovery_rejects_invalid_operation_id_before_recovery( - monkeypatch: pytest.MonkeyPatch, -) -> None: - def fail_recovery(*args: object, **kwargs: object) -> object: - raise AssertionError("recovery execution must not run for invalid operation IDs") - - monkeypatch.setattr(raw_module, "inspect_raw_authority_recovery", fail_recovery) - monkeypatch.setattr(raw_module, "resume_raw_authority_recovery", fail_recovery) - result = CliRunner().invoke( - raw_module.raw_authority_recovery_command, - ["--operation", "reset_raw_authority_census", "--operation-id", INVALID_OPERATION_ID], - obj=AppEnv(), - ) - - assert result.exit_code != 0 - assert "operation_id must not contain path separators" in result.output diff --git a/tests/unit/cli/test_reindex_canary_cli.py b/tests/unit/cli/test_reindex_canary_cli.py deleted file mode 100644 index f75fc5adf7..0000000000 --- a/tests/unit/cli/test_reindex_canary_cli.py +++ /dev/null @@ -1,1960 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import sqlite3 -from collections.abc import Generator, Mapping, Sequence -from pathlib import Path -from typing import IO, Any, cast - -import pytest -from click import Command -from click.testing import CliRunner as _ClickCliRunner -from click.testing import Result - -from polylogue.cli.click_app import cli -from polylogue.maintenance import reindex_canary as reindex_canary_module -from polylogue.maintenance.archive_verification import archive_verification_names_for_route - -CANARY_CHECKS = archive_verification_names_for_route("reindex-canary-candidate") -CANARY_PROFILE = "reindex-canary-v2-domain-coverage" -from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - rebuild_index_from_source_sync, - rebuild_selection_evidence, -) -from polylogue.maintenance.reindex_canary import ( - CanaryDifferenceReview, - CanaryDiffReport, - CanaryRunResult, - CanarySelection, - DifferenceClassification, - DifferenceOperation, - DurableCanaryReport, - RowDifference, - UnclassifiedCanaryDiffError, - run_reindex_canary, -) -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.archive_identity import ArchiveLocation -from polylogue.storage.index_generation import IndexGenerationStore, RebuildLease -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.archive_templates import clone_archive_template, finalize_archive_template -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - -_DEFAULT_CANARY_TEMPLATE: Path | None = None - - -@pytest.fixture(autouse=True) -def _run_cli_canary_tests_through_real_rebuild_service(monkeypatch: pytest.MonkeyPatch) -> None: - """CLI report tests exercise the real rebuild service while transport is tested separately.""" - - def rebuild_for_canary( - *, - archive_root: Path, - raw_ids: tuple[str, ...], - selected_session_ids: tuple[str, ...], - index_schema_version: int, - schema_inference_receipt_path: Path, - ) -> object: - return rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=archive_root, - raw_ids=raw_ids, - selected_session_ids=selected_session_ids, - promote=False, - canary=True, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - ) - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", rebuild_for_canary) - - def consume_for_cli(*, archive_root: Path, report_path: Path) -> dict[str, object]: - from polylogue.maintenance.reindex_canary import approve_canary_report - - return approve_canary_report(report_path, archive_root=archive_root) - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.consume_daemon_canary_report", consume_for_cli) - - -def _schema_receipt_path(root: Path) -> Path: - return root.parent / f"{root.name}-schema-inference-gate-receipt.json" - - -@pytest.fixture(scope="module", autouse=True) -def _default_canary_template(tmp_path_factory: pytest.TempPathFactory) -> Generator[None]: - """Build the default real archive once, then clone it for each canary test.""" - global _DEFAULT_CANARY_TEMPLATE - template = tmp_path_factory.mktemp("reindex-canary-template") / "archive" - _seed_isolated_canary(template) - finalize_archive_template(template) - _DEFAULT_CANARY_TEMPLATE = template - try: - yield - finally: - _DEFAULT_CANARY_TEMPLATE = None - - -def _rebase_canary_template(template: Path, root: Path) -> None: - """Point copied generation metadata and links at this test's private clone.""" - for path in root.rglob("*"): - if not path.is_symlink(): - continue - target = path.readlink() - if target.is_absolute() and target.is_relative_to(template): - target_is_directory = target.is_dir() - path.unlink() - path.symlink_to(root / target.relative_to(template), target_is_directory=target_is_directory) - pointer = root / ".index-active-pointer" - if pointer.is_file(): - target = Path(pointer.read_text(encoding="utf-8").strip()) - if target.is_absolute() and target.is_relative_to(template): - pointer.write_text(str(root / target.relative_to(template)), encoding="utf-8") - for metadata_path in root.glob(".index-generations/gen-*/generation.json"): - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - payload["archive_root"] = str(root.resolve()) - index_path = Path(str(payload["index_path"])) - if index_path.is_relative_to(template): - payload["index_path"] = str(root / index_path.relative_to(template)) - metadata_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - - -def _clone_default_canary_template(root: Path) -> bool: - template = _DEFAULT_CANARY_TEMPLATE - if template is None: - return False - clone_archive_template(template, root) - _rebase_canary_template(template, root) - write_valid_rebuild_receipt(root, _schema_receipt_path(root)) - return True - - -class _CanaryCliRunner(_ClickCliRunner): - """Supply the fixture's explicit receipt to legacy route invocations.""" - - def invoke( - self, - cli: Command, - args: str | Sequence[str] | None = None, - input: str | bytes | IO[Any] | None = None, - env: Mapping[str, str | None] | None = None, - catch_exceptions: bool | None = None, - color: bool = False, - **extra: Any, - ) -> Result: - command_args = list(args) if args is not None and not isinstance(args, str) else args - if ( - isinstance(command_args, list) - and "reindex-canary" in command_args - and "--consume-report" not in command_args - and "--schema-inference-receipt" not in command_args - and "--archive-root" in command_args - ): - archive_root = Path(command_args[command_args.index("--archive-root") + 1]) - receipt_path = _schema_receipt_path(archive_root) - if receipt_path.is_file(): - command_args.extend(("--schema-inference-receipt", str(receipt_path))) - return super().invoke( - cli, - command_args, - input=input, - env=env, - catch_exceptions=catch_exceptions, - color=color, - **extra, - ) - - -CliRunner = _CanaryCliRunner - - -def test_cli_consumption_dispatches_to_daemon_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The command uses the production daemon-client seam for report consumption.""" - - report_path = tmp_path / "report.json" - report_path.touch() - calls: dict[str, Path] = {} - - def consume(*, archive_root: Path, report_path: Path) -> dict[str, object]: - calls["archive_root"] = archive_root - calls["report_path"] = report_path - return {"review_status": "reviewed"} - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.consume_daemon_canary_report", consume) - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, result.output - assert calls == {"archive_root": tmp_path, "report_path": report_path} - assert json.loads(result.stdout)["decision"] == "evidence-approved" - - -def _codex_session(native_id: str) -> bytes: - rows = ( - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-08-04T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-user", - "role": "user", - "content": [{"type": "input_text", "text": "hello"}], - }, - }, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-assistant", - "role": "assistant", - "content": [{"type": "output_text", "text": "world"}], - }, - }, - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_isolated_canary( - root: Path, - *, - session_names: tuple[str, ...] = ("isolated-canary",), - membership_names: tuple[str, ...] = (), -) -> None: - if session_names == ("isolated-canary",) and not membership_names and _clone_default_canary_template(root): - return - initialize_active_archive_root(root) - paths = [] - for name in session_names: - path = root / "wire" / f"{name}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(_codex_session(name)) - paths.append(path) - result = admit_provider_source_packages(root, (provider_source_package("codex", (path,)) for path in paths)) - assert getattr(result, "parse_failures", 0) == 0 - if membership_names: - with sqlite3.connect(root / "source.db") as connection: - for name in membership_names: - raw_id, blob_hash = connection.execute( - "SELECT raw_id, blob_hash FROM raw_sessions WHERE source_path = ?", - (f"{name}.jsonl",), - ).fetchone() - connection.execute( - """ - INSERT INTO raw_session_memberships( - raw_id, logical_source_key, provider_session_id, source_revision, - normalized_content_hash, message_count, predecessor_raw_id, - acquisition_generation, revision_authority, decision, decided_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, NULL, 0, 'quarantined', NULL, NULL) - """, - (raw_id, f"codex-session:{name}", name, "1", blob_hash, 2), - ) - connection.commit() - backfill_historical_revision_evidence(root) - receipt_path = write_valid_rebuild_receipt(root, _schema_receipt_path(root)) - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=receipt_path) - ) - assert receipt.status == "replayed" - backfill_historical_revision_evidence(root) - # The final backfill can change durable source revision evidence. Refresh - # the fixture receipt only after that mutation so later canary routes see - # the same source snapshot the production preflight validates. - write_valid_rebuild_receipt(root, receipt_path) - - -def _write_real_unreviewed_canary_report( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - *, - name: str = "isolated-canary", - session_names: tuple[str, ...] = ("isolated-canary",), - sample: int = 1, -) -> tuple[Path, Path, dict[str, object]]: - """Exercise the CLI to produce a fully classified real canary report.""" - - live_root = tmp_path / "configured-live" - canary_root = tmp_path / name - report_path = tmp_path / f"{name}.json" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(canary_root)) - _seed_isolated_canary(canary_root, session_names=session_names) - monkeypatch.setattr("polylogue.config.resolve_archive_root", lambda: live_root) - with sqlite3.connect(canary_root / "index.db") as connection: - connection.execute("UPDATE sessions SET title_ref = NULL, title_confidence = NULL") - # Model the v43 -> v44 targeted title backfill: the old index shape - # lacks authored title values and the rebuilt candidate restores them. - # The stamped version is load-bearing -- an expected difference can - # only approve a canary whose compared generations cross the delta - # that authorizes it, and delta 44 is what owns these two columns. - connection.execute("PRAGMA user_version = 43") - - receipt_path = _schema_receipt_path(canary_root) - observed_result = run_reindex_canary( - canary_root, - input_index=canary_root / "index.db", - sessions_per_origin=sample, - sample_session_ids=(), - no_promote=True, - schema_inference_receipt_path=receipt_path, - ) - review_path = tmp_path / f"{name}-reviews.json" - review_path.write_text( - json.dumps( - { - "reviews": [ - { - "table": difference.table, - "operation": difference.operation.value, - "identity": dict(difference.identity), - "changed_columns": list(difference.changed_columns), - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed v44 title-reprocess difference", - } - for difference in observed_result.comparison.differences - ] - } - ), - encoding="utf-8", - ) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(report_path), - "--sample", - str(sample), - "--no-promote", - "--review-manifest", - str(review_path), - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, result.output - assert report_path.exists() - assert json.loads(report_path.read_text(encoding="utf-8"))["review_status"] == "reviewed" - return canary_root, report_path, json.loads(report_path.read_text(encoding="utf-8")) - - -def _write_review_manifest(path: Path, differences: list[dict[str, object]]) -> Path: - path.write_text( - json.dumps( - { - "reviews": [ - { - **difference, - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed v44 title-reprocess difference", - } - for difference in differences - ] - } - ), - encoding="utf-8", - ) - return path - - -def _write_reviewed_real_canary_report( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - *, - name: str, - session_names: tuple[str, ...] = ("isolated-canary",), -) -> tuple[Path, Path, dict[str, object]]: - canary_root, _observed_path, observed = _write_real_unreviewed_canary_report( - tmp_path, monkeypatch, name=name, session_names=session_names - ) - comparison = observed["comparison"] - assert isinstance(comparison, dict) - differences = comparison["differences"] - assert isinstance(differences, list) and differences - review_path = tmp_path / f"{name}-reviews.json" - review_path.write_text( - json.dumps( - { - "reviews": [ - { - "table": difference["table"], - "operation": difference["operation"], - "identity": difference["identity"], - "changed_columns": difference["changed_columns"], - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed v44 title-reprocess difference", - } - for difference in differences - ] - } - ), - encoding="utf-8", - ) - approved_path = tmp_path / f"{name}-approved.json" - generated = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(approved_path), - "--review-manifest", - str(review_path), - "--sample", - "1", - "--no-promote", - ], - catch_exceptions=False, - ) - assert generated.exit_code == 0, generated.output - return canary_root, approved_path, json.loads(approved_path.read_text(encoding="utf-8")) - - -def test_real_report_preserves_receipt_and_independent_source_snapshots( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - canary_root, report_path, payload = _write_real_unreviewed_canary_report(tmp_path, monkeypatch) - receipt = payload["rebuild_receipt"] - provenance = payload["archive_provenance"] - assert isinstance(receipt, dict) - assert isinstance(provenance, dict) - assert { - "archive_root", - "receipt_schema_version", - "source_evidence_after", - "selected_raw_count", - "selection_evidence", - "status", - "materialized", - "generation", - "transaction", - "operation", - } <= receipt.keys() - generation = receipt["generation"] - assert isinstance(generation, dict) - assert { - "generation_id", - "owner_id", - "archive_root", - "index_path", - "state", - "source_snapshot", - } <= generation.keys() - assert generation["state"] == "inactive" - assert provenance["archive_root"] == str(canary_root.resolve()) - assert provenance["candidate_generation"] == generation - assert provenance["source_snapshot"] == generation["source_snapshot"] - assert provenance["source_evidence_after"] == receipt["source_evidence_after"] - candidate_path = Path(str(generation["index_path"])) - assert json.loads((candidate_path.parent / "rebuild-receipt.json").read_text(encoding="utf-8")) == receipt - assert report_path.is_file() - - -def test_cli_rejects_same_count_selected_raw_id_swap_before_comparison( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - canary_root, report_path, payload = _write_real_unreviewed_canary_report( - tmp_path, - monkeypatch, - name="selection-swap", - session_names=("first", "second", "third"), - sample=2, - ) - selection = payload["selection"] - assert isinstance(selection, dict) - selected_raw_ids = selection["selected_raw_ids"] - assert isinstance(selected_raw_ids, list) and len(selected_raw_ids) == 2 - with sqlite3.connect(canary_root / "source.db") as connection: - source_raw_ids = [str(row[0]) for row in connection.execute("SELECT raw_id FROM raw_sessions")] - replacement = next(raw_id for raw_id in source_raw_ids if raw_id not in selected_raw_ids) - selection["selected_raw_ids"] = [selected_raw_ids[0], replacement] - report_path.write_text(json.dumps(payload), encoding="utf-8") - comparison_called = False - - def fail_if_compared(*args: object, **kwargs: object) -> object: - nonlocal comparison_called - comparison_called = True - raise AssertionError("selection swap reached the SQLite comparator") - - monkeypatch.setattr(reindex_canary_module, "compare_reindex_generations", fail_if_compared) - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert ( - "selection evidence cannot be recomputed" in consumed.output - or "selection does not match the authoritative rebuild receipt" in consumed.output - ) - assert not comparison_called - - -def test_cli_rejects_swapped_real_receipt_before_comparison(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - first_root, first_report, first_payload = _write_real_unreviewed_canary_report( - tmp_path, monkeypatch, name="first-canary" - ) - _second_root, _second_report, second_payload = _write_real_unreviewed_canary_report( - tmp_path, monkeypatch, name="second-canary" - ) - first_payload["rebuild_receipt"] = second_payload["rebuild_receipt"] - first_report.write_text(json.dumps(first_payload), encoding="utf-8") - comparison_called = False - - def fail_if_compared(*args: object, **kwargs: object) -> object: - nonlocal comparison_called - comparison_called = True - raise AssertionError("swapped receipt reached the SQLite comparator") - - monkeypatch.setattr(reindex_canary_module, "compare_reindex_generations", fail_if_compared) - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(first_root), - "--report", - str(first_report), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert ( - "does not identify the compared candidate" in consumed.output - or "different configured archive root" in consumed.output - ) - assert not comparison_called - - -def test_cli_rejects_real_receipt_identity_forgery(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - canary_root, report_path, payload = _write_real_unreviewed_canary_report(tmp_path, monkeypatch) - receipt = payload["rebuild_receipt"] - provenance = payload["archive_provenance"] - assert isinstance(receipt, dict) - assert isinstance(provenance, dict) - generation = receipt["generation"] - assert isinstance(generation, dict) - - forged_reports = { - "root": {"archive_provenance": {**provenance, "archive_root": str(tmp_path / "foreign-root")}}, - "generation": {"generation": {**generation, "generation_id": "gen-forged"}}, - "owner": {"generation": {**generation, "owner_id": "owner-forged"}}, - "candidate-path": {"generation": {**generation, "index_path": str(tmp_path / "foreign.db")}}, - "state": {"generation": {**generation, "state": "active"}}, - } - for name, changes in forged_reports.items(): - forged = json.loads(json.dumps(payload)) - assert isinstance(forged, dict) - forged_receipt = forged["rebuild_receipt"] - forged_provenance = forged["archive_provenance"] - assert isinstance(forged_receipt, dict) - assert isinstance(forged_provenance, dict) - archive_change = changes.get("archive_provenance") - if isinstance(archive_change, dict): - forged_provenance.update(archive_change) - else: - forged_generation = changes["generation"] - assert isinstance(forged_generation, dict) - forged_receipt["generation"] = forged_generation - forged_path = tmp_path / f"forged-{name}.json" - forged_path.write_text(json.dumps(forged), encoding="utf-8") - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(forged_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - assert consumed.exit_code == 1, name - assert ( - "archive-owned" in consumed.output - or "rebuild receipt" in consumed.output - or "canary report belongs" in consumed.output - or "candidate generation provenance" in consumed.output - ), ( - name, - consumed.output, - ) - - -def test_cli_consumes_valid_reviewed_real_report(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - canary_root, _observed_path, observed = _write_real_unreviewed_canary_report( - tmp_path, monkeypatch, name="valid-canary" - ) - comparison = observed["comparison"] - assert isinstance(comparison, dict) - differences = comparison["differences"] - assert isinstance(differences, list) and differences - review_path = tmp_path / "valid-reviews.json" - review_path.write_text( - json.dumps( - { - "reviews": [ - { - "table": difference["table"], - "operation": difference["operation"], - "identity": difference["identity"], - "changed_columns": difference["changed_columns"], - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed v44 title-reprocess difference", - } - for difference in differences - ] - } - ), - encoding="utf-8", - ) - approved_path = tmp_path / "approved.json" - generated = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(approved_path), - "--review-manifest", - str(review_path), - "--sample", - "1", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert generated.exit_code == 0, generated.output - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(approved_path), - "--consume-report", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert consumed.exit_code == 0, consumed.output - approved = json.loads(consumed.stdout) - assert approved["decision"] == "evidence-approved" - assert approved["promotion_authorized"] is False - - -def test_cli_rejects_foreign_receipt_root_before_opening_foreign_source_db( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A forged report root fails before selection evidence can read a foreign source tier.""" - - canary_root, report_path, _payload = _write_reviewed_real_canary_report( - tmp_path, monkeypatch, name="foreign-root-canary" - ) - foreign_root = tmp_path / "foreign-archive" - foreign_root.mkdir() - (foreign_root / "source.db").touch() - forged = json.loads(report_path.read_text(encoding="utf-8")) - receipt = forged["rebuild_receipt"] - provenance = forged["archive_provenance"] - assert isinstance(receipt, dict) - assert isinstance(provenance, dict) - generation = receipt["generation"] - assert isinstance(generation, dict) - receipt["archive_root"] = str(foreign_root) - generation["archive_root"] = str(foreign_root) - provenance["archive_root"] = str(foreign_root) - forged_path = tmp_path / "foreign-root-forged.json" - forged_path.write_text(json.dumps(forged), encoding="utf-8") - - monkeypatch.setattr( - sqlite3, - "connect", - lambda *args, **kwargs: pytest.fail("foreign source.db was opened"), - ) - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(forged_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert "different configured archive root" in consumed.output - - -def test_cli_consumes_reviewed_report_after_parsed_state_mutation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Parser bookkeeping may evolve after replay without changing source evidence.""" - - canary_root, _observed_path, observed = _write_real_unreviewed_canary_report( - tmp_path, monkeypatch, name="parsed-state-canary" - ) - comparison = observed["comparison"] - assert isinstance(comparison, dict) - differences = comparison["differences"] - assert isinstance(differences, list) and differences - review_path = tmp_path / "parsed-state-reviews.json" - review_path.write_text( - json.dumps( - { - "reviews": [ - { - "table": difference["table"], - "operation": difference["operation"], - "identity": difference["identity"], - "changed_columns": difference["changed_columns"], - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed v44 title-reprocess difference", - } - for difference in differences - ] - } - ), - encoding="utf-8", - ) - approved_path = tmp_path / "parsed-state-approved.json" - generated = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(approved_path), - "--review-manifest", - str(review_path), - "--sample", - "1", - "--no-promote", - ], - catch_exceptions=False, - ) - assert generated.exit_code == 0, generated.output - - with sqlite3.connect(canary_root / "source.db") as connection: - connection.execute("UPDATE raw_sessions SET parsed_at_ms = COALESCE(parsed_at_ms, 0) + 1") - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(approved_path), - "--consume-report", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 0, consumed.output - approved = json.loads(consumed.stdout) - assert approved["decision"] == "evidence-approved" - assert approved["promotion_authorized"] is False - - -def test_cli_rejects_membership_and_logical_key_expansion_drift( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A new durable cohort member invalidates the original replay closure.""" - - canary_root = tmp_path / "closure-drift-canary" - report_path = tmp_path / "closure-drift-canary.json" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(canary_root)) - _seed_isolated_canary( - canary_root, - session_names=("selected", "unselected"), - ) - monkeypatch.setattr("polylogue.config.resolve_archive_root", lambda: tmp_path / "configured-live") - with sqlite3.connect(canary_root / "index.db") as connection: - connection.execute("UPDATE sessions SET title_ref = NULL, title_confidence = NULL") - observed_result = run_reindex_canary( - canary_root, - input_index=canary_root / "index.db", - sessions_per_origin=1, - no_promote=True, - schema_inference_receipt_path=_schema_receipt_path(canary_root), - ) - review_path = tmp_path / "membership-reviews.json" - _write_review_manifest(review_path, [difference.to_dict() for difference in observed_result.comparison.differences]) - review_payload = json.loads(review_path.read_text(encoding="utf-8")) - for review in review_payload["reviews"]: - review["classification"] = "unexpected" - review["reference"] = "successor:polylogue-ox2iz" - review["authority"] = {"kind": "successor", "id": "polylogue-ox2iz"} - review["rationale"] = "reviewed real canary difference" - review_path.write_text(json.dumps(review_payload), encoding="utf-8") - generated = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(report_path), - "--review-manifest", - str(review_path), - "--sample", - "1", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert generated.exit_code == 0, generated.output - payload = json.loads(report_path.read_text(encoding="utf-8")) - selection = payload["selection"] - assert isinstance(selection, dict) - selected_raw_ids = selection["selected_raw_ids"] - assert isinstance(selected_raw_ids, list) and len(selected_raw_ids) == 1 - with sqlite3.connect(canary_root / "source.db") as connection: - raw_ids = [str(row[0]) for row in connection.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id")] - selected_raw_id = str(selected_raw_ids[0]) - unselected_raw_id = next(raw_id for raw_id in raw_ids if raw_id != selected_raw_id) - selected_blob_hash = connection.execute( - "SELECT blob_hash FROM raw_sessions WHERE raw_id = ?", (selected_raw_id,) - ).fetchone()[0] - connection.execute( - "DELETE FROM raw_session_memberships WHERE raw_id IN (?, ?)", - (selected_raw_id, unselected_raw_id), - ) - connection.executemany( - """ - INSERT INTO raw_session_memberships( - raw_id, logical_source_key, provider_session_id, source_revision, - normalized_content_hash, message_count, predecessor_raw_id, - acquisition_generation, revision_authority, decision, decided_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'quarantined', NULL, NULL) - """, - [ - ( - selected_raw_id, - "codex-session:selected", - "selected", - "1", - selected_blob_hash, - 2, - None, - 0, - ), - ( - unselected_raw_id, - "codex-session:selected", - "selected", - "1", - selected_blob_hash, - 2, - None, - 0, - ), - ], - ) - connection.commit() - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert "selection does not match the authoritative rebuild receipt" in consumed.output - - -def test_cli_rejects_tampered_raw_payload_bytes_during_approval( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A live raw blob must still hash correctly when a reviewed report is consumed.""" - - canary_root, report_path, payload = _write_reviewed_real_canary_report( - tmp_path, monkeypatch, name="tampered-blob-canary" - ) - selection = payload["selection"] - assert isinstance(selection, dict) - selected_raw_ids = selection["selected_raw_ids"] - assert isinstance(selected_raw_ids, list) and selected_raw_ids - with sqlite3.connect(canary_root / "source.db") as connection: - blob_hash = connection.execute( - "SELECT lower(hex(blob_hash)) FROM raw_sessions WHERE raw_id = ?", (selected_raw_ids[0],) - ).fetchone()[0] - blob_path = canary_root / "blob" / str(blob_hash)[:2] / str(blob_hash)[2:] - blob_path.write_bytes(b"tampered raw payload bytes") - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert "blob bytes failed verification" in consumed.output - - -def test_cli_rejects_source_mutation_between_approval_checks_and_preserves_active_index( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Approval revalidates source evidence after its initial report check.""" - - canary_root, report_path, payload = _write_reviewed_real_canary_report( - tmp_path, monkeypatch, name="approval-mutation-canary" - ) - active_before = (canary_root / "index.db").read_bytes() - real_load = reindex_canary_module.load_canary_report - calls = 0 - - def mutate_after_initial_check(path: Path, *, archive_root: Path | None = None) -> dict[str, object]: - nonlocal calls - calls += 1 - result = real_load(path, archive_root=archive_root) - if calls == 1: - selection = payload["selection"] - assert isinstance(selection, dict) - selected_raw_ids = selection["selected_raw_ids"] - assert isinstance(selected_raw_ids, list) and selected_raw_ids - with sqlite3.connect(canary_root / "source.db") as connection: - connection.execute( - "UPDATE raw_sessions SET source_index = source_index + 1 WHERE raw_id = ?", - (selected_raw_ids[0],), - ) - connection.commit() - return result - - monkeypatch.setattr(reindex_canary_module, "load_canary_report", mutate_after_initial_check) - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert calls == 2 - assert (canary_root / "index.db").read_bytes() == active_before - - -def test_cli_rejects_candidate_promotion_between_approval_checks( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A candidate promoted after the first check cannot receive approval.""" - - canary_root, report_path, payload = _write_reviewed_real_canary_report( - tmp_path, monkeypatch, name="approval-promotion-canary" - ) - real_load = reindex_canary_module.load_canary_report - promoted = False - - def promote_after_initial_check(path: Path, *, archive_root: Path | None = None) -> dict[str, object]: - nonlocal promoted - result = real_load(path, archive_root=archive_root) - if not promoted: - receipt = payload["rebuild_receipt"] - assert isinstance(receipt, dict) - generation = receipt["generation"] - assert isinstance(generation, dict) - location = ArchiveLocation.resolve(canary_root) - store = IndexGenerationStore(location) - store.promote(store.load(str(generation["generation_id"]))) - promoted = True - return result - - monkeypatch.setattr(reindex_canary_module, "load_canary_report", promote_after_initial_check) - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert promoted is True - assert ( - "candidate generation" in consumed.output - or "active pointer" in consumed.output - or "stale for the current active generation" in consumed.output - ) - - -def test_cli_consumption_obeys_rebuild_lease(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Report consumption cannot race an active rebuild owner.""" - - canary_root, report_path, _payload = _write_reviewed_real_canary_report( - tmp_path, monkeypatch, name="approval-lock-canary" - ) - with RebuildLease(canary_root): - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert consumed.exit_code == 1 - assert "rebuild lease" in consumed.output or "already held" in consumed.output - - -def _run_result(index_path: Path, *, differences: tuple[object, ...] = ()) -> CanaryRunResult: - selection = CanarySelection( - index_path=index_path, - sessions_per_origin=2, - selected_session_ids=("codex-session:sample",), - selected_raw_ids=("raw-sample",), - sampled_session_ids=("codex-session:sample",), - pathology_session_ids=("codex-session:pathology",), - sample_session_ids=("codex-session:sample",), - origin_counts=(("codex-session", 1),), - ) - comparison = CanaryDiffReport( - current_index=index_path, - candidate_index=index_path.with_name("candidate.db"), - session_ids=selection.selected_session_ids, - compared_tables=("sessions",), - missing_tables=(), - missing_columns=(), - differences=differences, # type: ignore[arg-type] - ) - return CanaryRunResult( - selection=selection, - comparison=comparison, - rebuild_receipt={ - "receipt_schema_version": 5, - "archive_root": str(index_path.parent), - "selected_raw_count": len(selection.selected_raw_ids), - "status": "replayed", - "materialized": True, - "generation": { - "generation_id": "gen-sample", - "owner_id": "owner-sample", - "archive_root": str(index_path.parent), - "index_path": str(comparison.candidate_index), - "state": "inactive", - "source_snapshot": "snapshot", - }, - "selection_evidence": rebuild_selection_evidence( - selection.selected_raw_ids, - archive_root=index_path.parent, - generation_id="gen-sample", - generation_owner_id="owner-sample", - candidate_index=comparison.candidate_index, - source_snapshot="snapshot", - selected_session_ids=selection.selected_session_ids, - ), - "source_evidence_after": "0" * 64, - "canary_acceptance": _canary_acceptance_attestation(), - }, - ) - - -def _canary_acceptance_attestation() -> dict[str, object]: - """Return the daemon-owned acceptance evidence required of canary receipts.""" - - return { - "profile": CANARY_PROFILE, - "results": [ - {"name": name, "status": "ok", "summary": "fixture acceptance", "count": 0} for name in CANARY_CHECKS - ], - } - - -def _nonempty_run_result(index_path: Path) -> CanaryRunResult: - difference = RowDifference( - table="sessions", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "codex-session:sample"),), - before={"title_ref": None}, - after={"title_ref": "message:codex-session:sample:user"}, - changed_columns=("title_ref",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - result = _run_result(index_path, differences=(difference,)) - return CanaryRunResult( - selection=result.selection, - comparison=result.comparison, - rebuild_receipt={ - "receipt_schema_version": 5, - "archive_root": str(index_path.parent), - "selected_raw_count": 1, - "status": "replayed", - "materialized": True, - "generation": { - "generation_id": "gen-canary", - "owner_id": "owner", - "archive_root": str(index_path.parent), - "index_path": str(result.comparison.candidate_index), - "state": "inactive", - "source_snapshot": "snapshot", - }, - "selection_evidence": rebuild_selection_evidence( - result.selection.selected_raw_ids, - archive_root=index_path.parent, - generation_id="gen-canary", - generation_owner_id="owner", - candidate_index=result.comparison.candidate_index, - source_snapshot="snapshot", - selected_session_ids=result.selection.selected_session_ids, - ), - "source_evidence_after": "0" * 64, - "canary_acceptance": _canary_acceptance_attestation(), - }, - ) - - -def test_reindex_canary_cli_requires_no_promote(tmp_path: Path) -> None: - index_path = tmp_path / "index.db" - index_path.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--input", - str(index_path), - "--report", - str(tmp_path / "canary.json"), - "--schema-inference-receipt", - str(receipt_path), - ], - ) - - assert result.exit_code == 2 - assert "requires --no-promote" in result.output - - -def test_reindex_canary_cli_rejects_input_outside_archive_root_before_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - archive_root = tmp_path / "archive" - archive_root.mkdir() - external_index = tmp_path / "external" / "index.db" - external_index.parent.mkdir() - external_index.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path / "configured-live")) - rebuild_called = False - - def unexpected_rebuild(*args: object, **kwargs: object) -> None: - nonlocal rebuild_called - rebuild_called = True - raise AssertionError("the CLI must reject an outside-root input before rebuild") - - monkeypatch.setattr("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", unexpected_rebuild) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(archive_root), - "--input", - str(external_index), - "--report", - str(tmp_path / "canary.json"), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - ], - ) - - assert result.exit_code == 1, result.output - assert "inside or bound to the selected archive root" in result.output - assert not rebuild_called - - -def test_reindex_canary_cli_runs_real_no_promote_route( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - archive_root = tmp_path / "isolated-canary" - _seed_isolated_canary(archive_root) - with sqlite3.connect(archive_root / "index.db") as connection: - connection.execute("UPDATE blocks SET text = 'mutated active projection'") - index_path = archive_root / "index.db" - report_path = tmp_path / "reports" / "canary.json" - receipt_path = write_valid_rebuild_receipt(archive_root, tmp_path / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path / "configured-live-root")) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(archive_root), - "--input", - str(index_path), - "--sample", - "1000", - "--report", - str(report_path), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 1, result.output - assert "persisted unreviewed" in result.output - assert "refuses the configured live archive root" not in result.output - assert json.loads(report_path.read_text(encoding="utf-8"))["review_status"] == "unreviewed" - - -def test_reindex_canary_cli_refuses_to_write_unclassified_report( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - index_path = tmp_path / "index.db" - index_path.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - run_result = _run_result(index_path, differences=(object(),)) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.run_reindex_canary", lambda *args, **kwargs: run_result) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.write_canary_report", - lambda *args, **kwargs: (_ for _ in ()).throw(UnclassifiedCanaryDiffError("classification is incomplete")), - ) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--input", - str(index_path), - "--report", - str(tmp_path / "canary.json"), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - ], - ) - - assert result.exit_code == 1 - assert "classification is incomplete" in result.output - - -def test_reindex_canary_cli_persists_review_manifest_for_nonempty_differences( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The CLI forwards an explicit per-difference review to report writing.""" - - index_path = tmp_path / "index.db" - index_path.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - report_path = tmp_path / "canary.json" - review_path = tmp_path / "reviews.json" - run_result = _nonempty_run_result(index_path) - difference = run_result.comparison.differences[0] - assert isinstance(difference, RowDifference) - review = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="reviewed title-reprocess change", - ) - captured: dict[str, object] = {} - - def fake_write(path: Path, **kwargs: object) -> DurableCanaryReport: - captured["path"] = path - captured["reviews"] = kwargs["reviews"] - return DurableCanaryReport( - selection=run_result.selection, - comparison=run_result.comparison, - rebuild_receipt=run_result.rebuild_receipt, - reviews=(review,), - review_status="reviewed", - comparison_fingerprint="0" * 64, - archive_provenance={}, - ) - - review_path.write_text(json.dumps({"reviews": [review.to_dict()]}), encoding="utf-8") - monkeypatch.setattr("polylogue.maintenance.reindex_canary.run_reindex_canary", lambda *args, **kwargs: run_result) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.write_canary_report", fake_write) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.load_canary_report", lambda path, **kwargs: {}) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--input", - str(index_path), - "--report", - str(report_path), - "--review-manifest", - str(review_path), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert captured == {"path": report_path, "reviews": (review,)} - assert json.loads(result.stdout)["reviews"] == [review.to_dict()] - - -def test_reindex_canary_cli_rejects_manifest_with_wrong_changed_columns( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Manifest coverage is exact down to the changed-column signature.""" - - index_path = tmp_path / "index.db" - index_path.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - report_path = tmp_path / "canary.json" - review_path = tmp_path / "reviews.json" - run_result = _nonempty_run_result(index_path) - difference = run_result.comparison.differences[0] - assert isinstance(difference, RowDifference) - review = CanaryDifferenceReview( - table=difference.table, - operation=difference.operation, - identity=difference.identity, - changed_columns=("different_column",), - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="wrong title-reprocess signature", - ) - review_path.write_text(json.dumps({"reviews": [review.to_dict()]}), encoding="utf-8") - monkeypatch.setattr("polylogue.maintenance.reindex_canary.run_reindex_canary", lambda *args, **kwargs: run_result) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--input", - str(index_path), - "--report", - str(report_path), - "--review-manifest", - str(review_path), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - # Name the offending column rather than a refusal sentence: the wording is - # the CLI's to change, but a rejection that does not tell the operator - # which declaration failed to cover the diff is not a usable rejection. - assert "different_column" in result.output - assert "sessions" in result.output - assert not report_path.exists() - - -def test_reindex_canary_cli_refuses_nonempty_differences_without_review_manifest( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - index_path = tmp_path / "index.db" - index_path.touch() - receipt_path = tmp_path / "schema-inference-gate-receipt.json" - receipt_path.touch() - run_result = _nonempty_run_result(index_path) - - def fake_write(path: Path, **kwargs: object) -> DurableCanaryReport: - raise UnclassifiedCanaryDiffError("classification is incomplete") - - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.run_reindex_canary", - lambda *args, **kwargs: run_result, - ) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.write_canary_report", fake_write) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.load_canary_report", lambda path, **kwargs: {}) - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(tmp_path), - "--input", - str(index_path), - "--report", - str(tmp_path / "canary.json"), - "--schema-inference-receipt", - str(receipt_path), - "--no-promote", - ], - ) - - assert result.exit_code == 1 - assert "classification is incomplete" in result.output - - -def test_reindex_canary_cli_persists_unreviewed_real_candidate_for_later_review( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A real inactive rebuild persists discovery evidence but cannot approve it.""" - - live_root = tmp_path / "configured-live" - canary_root = tmp_path / "isolated-canary" - report_path = tmp_path / "unreviewed.json" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(canary_root)) - _seed_isolated_canary(canary_root) - monkeypatch.setattr("polylogue.config.resolve_archive_root", lambda: live_root) - with sqlite3.connect(canary_root / "index.db") as connection: - connection.execute("UPDATE sessions SET title_ref = NULL, title_confidence = NULL") - - result = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(report_path), - "--sample", - "1", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - assert "persisted unreviewed" in result.output - assert json.loads(report_path.read_text(encoding="utf-8"))["review_status"] == "unreviewed" - - -def test_cli_canary_report_red_twin_rejects_arbitrary_copied_indexes( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A copied pair must not become an approved archive just by rewriting JSON.""" - - canary_root, report_path, payload = _write_real_unreviewed_canary_report(tmp_path, monkeypatch) - comparison = payload["comparison"] - receipt = payload["rebuild_receipt"] - selection = payload["selection"] - assert isinstance(comparison, dict) - assert isinstance(receipt, dict) - assert isinstance(selection, dict) - generation = receipt["generation"] - assert isinstance(generation, dict) - original_candidate = Path(str(comparison["candidate_index"])) - - copied_root = tmp_path / "copied-archive" - copied_candidate = copied_root / "unowned-copy" / "index.db" - copied_current = copied_root / "index.db" - copied_candidate.parent.mkdir(parents=True) - shutil.copy2(canary_root / "source.db", copied_root / "source.db") - shutil.copy2(canary_root / "index.db", copied_current) - shutil.copy2(original_candidate, copied_candidate) - - comparison["current_index"] = str(copied_current) - comparison["candidate_index"] = str(copied_candidate) - selection["index_path"] = str(copied_current) - receipt["archive_root"] = str(copied_root) - generation["archive_root"] = str(copied_root) - generation["index_path"] = str(copied_candidate) - payload["comparison_fingerprint"] = reindex_canary_module._comparison_fingerprint( - reindex_canary_module.compare_reindex_generations( - copied_current, - copied_candidate, - session_ids=tuple(selection["selected_session_ids"]), - ) - ) - report_path.write_text(json.dumps(payload), encoding="utf-8") - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - assert consumed.exit_code == 1 - assert ( - "archive-owned" in consumed.output - or "authoritative rebuild receipt" in consumed.output - or "different configured archive root" in consumed.output - ) - - -def test_cli_canary_report_red_twin_rejects_replaced_candidate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Replacing the same candidate path after review must invalidate the report.""" - - _canary_root, report_path, payload = _write_real_unreviewed_canary_report(tmp_path, monkeypatch) - comparison = payload["comparison"] - assert isinstance(comparison, dict) - candidate = Path(str(comparison["candidate_index"])) - replacement = tmp_path / "replacement.db" - shutil.copy2(candidate, replacement) - replacement.replace(candidate) - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(_canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - assert consumed.exit_code == 1 - assert "candidate index identity" in consumed.output - - -@pytest.mark.parametrize( - "drift", - ( - "active-pointer", - "candidate-generation", - "source-byte", - "source-blob-ref", - "source-observation", - "source-snapshot", - ), -) -def test_cli_canary_report_red_twin_rejects_lifecycle_drift( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, drift: str -) -> None: - """The report is invalid when live pointer or generation metadata no longer match.""" - - canary_root, report_path, payload = _write_real_unreviewed_canary_report(tmp_path, monkeypatch) - comparison = payload["comparison"] - assert isinstance(comparison, dict) - if drift == "active-pointer": - alternate = canary_root / "alternate-generation" / "index.db" - alternate.parent.mkdir() - shutil.copy2(canary_root / "index.db", alternate) - (canary_root / ".index-active-pointer").write_text(str(alternate), encoding="utf-8") - elif drift == "candidate-generation": - candidate_metadata = Path(str(comparison["candidate_index"])).with_name("generation.json") - metadata = json.loads(candidate_metadata.read_text(encoding="utf-8")) - metadata["owner_id"] = "replaced-owner" - candidate_metadata.write_text(json.dumps(metadata), encoding="utf-8") - elif drift == "source-byte": - with sqlite3.connect(canary_root / "source.db") as connection: - connection.execute("UPDATE raw_sessions SET blob_hash = zeroblob(length(blob_hash))") - elif drift == "source-blob-ref": - with sqlite3.connect(canary_root / "source.db") as connection: - raw_id = connection.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id LIMIT 1").fetchone()[0] - connection.execute( - """ - UPDATE blob_refs - SET acquired_at_ms = acquired_at_ms + 1 - WHERE ref_type = 'raw_payload' AND ref_id = ? - """, - (raw_id,), - ) - elif drift == "source-observation": - with sqlite3.connect(canary_root / "source.db") as connection: - raw_id = connection.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id LIMIT 1").fetchone()[0] - connection.execute( - """ - INSERT INTO raw_capture_observations (raw_id, capture_mode, first_observed_at_ms) - VALUES (?, 'gemini', 999) - """, - (raw_id,), - ) - else: - with sqlite3.connect(canary_root / "source.db") as connection: - connection.execute("UPDATE raw_sessions SET acquired_at_ms = acquired_at_ms + 1") - - consumed = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--report", - str(report_path), - "--consume-report", - "--no-promote", - ], - catch_exceptions=False, - ) - assert consumed.exit_code == 1 - assert "archive-owned" in consumed.output or "selection" in consumed.output - - -def test_reindex_canary_cli_rejects_manifest_with_mismatched_changed_columns_from_real_diff( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """CLI review manifests must bind each real difference's column signature.""" - - live_root = tmp_path / "configured-live" - canary_root = tmp_path / "isolated-canary" - rejected_report_path = tmp_path / "rejected.json" - review_path = tmp_path / "reviews.json" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(canary_root)) - _seed_isolated_canary(canary_root) - monkeypatch.setattr("polylogue.config.resolve_archive_root", lambda: live_root) - with sqlite3.connect(canary_root / "index.db") as connection: - connection.execute("UPDATE sessions SET title_ref = NULL, title_confidence = NULL") - - observed_result = run_reindex_canary( - canary_root, - input_index=canary_root / "index.db", - sessions_per_origin=1, - no_promote=True, - schema_inference_receipt_path=_schema_receipt_path(canary_root), - ) - differences = [difference.to_dict() for difference in observed_result.comparison.differences] - assert differences - reviews = [ - { - "table": difference["table"], - "operation": difference["operation"], - "identity": difference["identity"], - "changed_columns": difference["changed_columns"], - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "reviewed title-reprocess change", - } - for difference in differences - ] - reviews[0]["changed_columns"] = ["forged_column"] - review_path.write_text(json.dumps({"reviews": reviews}), encoding="utf-8") - - rejected = CliRunner().invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "reindex-canary", - "--archive-root", - str(canary_root), - "--input", - str(canary_root / "index.db"), - "--report", - str(rejected_report_path), - "--review-manifest", - str(review_path), - "--sample", - "1", - "--no-promote", - ], - catch_exceptions=False, - ) - - assert rejected.exit_code == 1 - assert "forged_column" in rejected.output - assert "sessions" in rejected.output - assert not rejected_report_path.exists() - - -def test_shared_canary_runner_uses_existing_inactive_rebuild_route( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - current_index = tmp_path / "index.db" - candidate_index = tmp_path / ".index-generations" / "gen-test" / "index.db" - current_index.touch() - candidate_index.parent.mkdir(parents=True) - candidate_index.touch() - selection = _run_result(current_index).selection - captured: dict[str, object] = {} - - class Receipt: - archive_root = str(tmp_path.resolve()) - selected_raw_count = len(selection.selected_raw_ids) - status = "replayed" - materialized = True - generation = { - "generation_id": "gen-test", - "owner_id": "owner", - "archive_root": str(tmp_path.resolve()), - "index_path": str(candidate_index), - "state": "inactive", - "source_snapshot": "snapshot", - } - selection_evidence = rebuild_selection_evidence( - selection.selected_raw_ids, - archive_root=tmp_path, - generation_id="gen-test", - generation_owner_id="owner", - candidate_index=candidate_index, - source_snapshot="snapshot", - selected_session_ids=selection.selected_session_ids, - ) - canary_acceptance = _canary_acceptance_attestation() - - def to_dict(self) -> dict[str, object]: - return { - "archive_root": self.archive_root, - "selected_raw_count": self.selected_raw_count, - "status": self.status, - "materialized": self.materialized, - "generation": self.generation, - "selection_evidence": self.selection_evidence, - "canary_acceptance": self.canary_acceptance, - } - - def fake_rebuild(**request: object) -> Receipt: - captured["request"] = request - return Receipt() - - def fake_compare( - current: Path, - candidate: Path, - *, - session_ids: tuple[str, ...], - **provenance: object, - ) -> CanaryDiffReport: - captured["compare"] = (current, candidate, session_ids) - captured.update(provenance) - return CanaryDiffReport( - current_index=current, - candidate_index=candidate, - session_ids=session_ids, - compared_tables=("sessions",), - missing_tables=(), - missing_columns=(), - differences=(), - ) - - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.select_canary_sessions", lambda *args, **kwargs: selection - ) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", fake_rebuild) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.compare_reindex_generations", fake_compare) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_authoritative_rebuild_receipt", lambda *args, **kwargs: None - ) - - result = run_reindex_canary( - tmp_path, - input_index=current_index, - schema_inference_receipt_path=tmp_path / "schema-inference-gate-receipt.json", - sessions_per_origin=2, - no_promote=True, - ) - - request = cast(dict[str, object], captured["request"]) - assert request["raw_ids"] == selection.selected_raw_ids - assert request["selected_session_ids"] == selection.selected_session_ids - assert captured["compare"] == (current_index, candidate_index, selection.selected_session_ids) - assert result.rebuild_receipt == { - "archive_root": Receipt.archive_root, - "selected_raw_count": Receipt.selected_raw_count, - "status": Receipt.status, - "materialized": Receipt.materialized, - "generation": Receipt.generation, - "selection_evidence": Receipt.selection_evidence, - "canary_acceptance": Receipt.canary_acceptance, - } diff --git a/tests/unit/daemon/test_bulk_rebuild.py b/tests/unit/daemon/test_bulk_rebuild.py deleted file mode 100644 index 394b015787..0000000000 --- a/tests/unit/daemon/test_bulk_rebuild.py +++ /dev/null @@ -1,679 +0,0 @@ -"""Tests for polylogue-gd6v's daemon-internal bulk-scale rebuild routing. - -Production dependencies exercised here: - -* ``polylogue.daemon.bulk_rebuild`` -- the actual transaction resolve/resume/ - retire logic and the pass driver, not a reimplementation. -* ``polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync`` -- - the SAME engine the offline ``polylogue ops maintenance rebuild-index`` - CLI command drives (this module's whole point is to reuse it, not - duplicate it). -* ``polylogue.daemon.parse_prefetch.DaemonParseStage`` -- the real #3168 - off-writer-hold pre-parse pool, feeding the real - ``RawParsePrefetchCache``/``prefetch_cache`` production plumbing threaded - through ``backfill_historical_revision_evidence`` by this bead. - -Two claims this file proves: - -1. **Equivalence** (gd6v AC): driving the SAME corpus through (a) the - existing single-call CLI rebuild path and (b) the new daemon bulk-rebuild - routing (multiple bounded passes, parse pre-warmed off the writer hold) - produces identical durable archive content -- sessions/messages/blocks, - content hashes, session_links, and FTS row counts. -2. **O(remaining-work) resume** (polylogue-fbte, folded into this bead's - acceptance gate): each bounded pass's transaction cursor only ever moves - forward -- a later pass's scheduled page is disjoint from every earlier - pass's page -- and a daemon "restart" (a fresh ``DaemonParseStage``, - mirroring a fresh process) resumes from the persisted cursor rather than - re-selecting already-processed raws. -""" - -from __future__ import annotations - -import asyncio -import json -import sqlite3 -import threading -from dataclasses import asdict -from pathlib import Path -from typing import Any -from unittest.mock import Mock - -import pytest - -from polylogue.config import Config, Source -from polylogue.core.enums import Provider -from polylogue.daemon.bulk_rebuild import ( - DAEMON_BULK_REBUILD_OPERATION_ID, - has_resumable_daemon_bulk_rebuild_transaction, - resolve_or_start_daemon_bulk_rebuild_transaction, - run_daemon_bulk_rebuild_pass, -) -from polylogue.daemon.parse_prefetch import DaemonParseStage -from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - RebuildSchemaCurrencyError, - rebuild_index_from_source_sync, -) -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.archive_identity import ArchiveLocation, OwnedArchiveLocation, assert_owns_archive_location -from polylogue.storage.archive_readiness import probe_archive_tier -from polylogue.storage.index_generation import ( - IndexGenerationStore, - IndexRebuildTransaction, - rebuild_source_evidence_snapshot, - source_revision_snapshot, -) -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - -_RAW_COUNT = 6 - - -def _codex_session(native_id: str, messages: tuple[tuple[str, str], ...]) -> bytes: - import json - - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-20T00:00:00Z"}} - ] - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [ - { - "type": "input_text" if role == "user" else "output_text", - "text": text, - } - ], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _config(root: Path, *, sources: list[Source] | None = None) -> Config: - return Config(archive_root=root, render_root=root / "render", sources=sources or []) - - -def _seed_corpus(root: Path, *, count: int = _RAW_COUNT) -> None: - initialize_active_archive_root(root) - paths = [] - for index in range(count): - payload = _codex_session( - f"gd6v-session-{index}", - (("user", f"question {index}"), ("assistant", f"searchable answer {index}")), - ) - path = root / "wire" / f"gd6v-corpus-{index}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(payload) - paths.append(path) - result = admit_provider_source_packages(root, (provider_source_package("codex", (path,)) for path in paths)) - assert getattr(result, "parse_failures", 0) == 0 - backfill_historical_revision_evidence(root) - - -def _connect(path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) - conn.row_factory = sqlite3.Row - return conn - - -def test_bulk_rebuild_fences_timed_out_parse_worker_before_bulk_writer( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A live timed-out bulk parse cannot acquire the bulk writer actor.""" - import polylogue.daemon.write_coordinator as write_coordinator_module - from polylogue.daemon.write_coordinator import DaemonWriteCoordinator - from polylogue.sources import revision_backfill - - _seed_corpus(tmp_path, count=1) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / "bulk-timeout-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - started = threading.Event() - release = threading.Event() - real_worker: Any = revision_backfill.census_parse_worker - - def blocked_worker(*args: object, **kwargs: object) -> object: - started.set() - release.wait(timeout=5) - return real_worker(*args, **kwargs) - - monkeypatch.setattr(revision_backfill, "census_parse_worker", blocked_worker) - coordinator_events: list[object] = [] - coordinator = DaemonWriteCoordinator(observer=coordinator_events.append) - monkeypatch.setattr(write_coordinator_module, "daemon_write_coordinator", lambda: coordinator) - parse_stage = DaemonParseStage(max_workers=1, max_inflight_bytes=10_000_000, warm_timeout_seconds=0.01) - try: - receipt = asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path), - parse_stage=parse_stage, - batch_size=1, - max_payload_bytes=10_000_000, - ) - ) - assert receipt is None - assert started.wait(timeout=2) - # A second caller sees the same still-running worker and must be - # fenced again; no retry may reach coordinator admission by assuming - # the first cancelled caller released the executor. - events_before_retry = len(coordinator_events) - assert ( - asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path), - parse_stage=parse_stage, - batch_size=1, - max_payload_bytes=10_000_000, - ) - ) - is None - ) - assert not any( - getattr(event, "phase", None) == "acquired" - and getattr(event, "actor", None) - in { - "maintenance.bulk_rebuild_admission", - "maintenance.bulk_rebuild", - } - for event in coordinator_events[events_before_retry:] - ) - finally: - release.set() - assert parse_stage.wait_until_idle(timeout=5) - parse_stage.shutdown() - assert asyncio.run(coordinator.shutdown(timeout=2)) is True - - -def test_daemon_bulk_rebuild_refuses_unexplained_failures_before_generation_or_page_selection( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The real daemon route fails before creating state or selecting raws.""" - initialize_active_archive_root(tmp_path) - # Acquire real bytes and then mark the parse as failed. A hand-inserted row - # with a fabricated blob_hash fails the receipt's blob verification, so the - # run would refuse for a missing blob rather than for the unexplained parse - # failure this case is about. - payload = _codex_session("bulk-rebuild-failed", (("user", "question"), ("assistant", "answer"))) - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - failed_raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=payload, - source_path="bulk-rebuild-failed.jsonl", - acquired_at_ms=100, - ) - with sqlite3.connect(tmp_path / "source.db") as conn: - conn.execute( - "UPDATE raw_sessions SET parse_error = 'parser failed' WHERE raw_id = ?", - (failed_raw_id,), - ) - conn.commit() - - # Written after seeding: the receipt binds to a source snapshot, so one - # taken earlier is rejected for not matching source.db and the run refuses - # on the receipt instead of on the failure lifecycle this case is about. - monkeypatch.setenv( - "POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", - str(write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-failure-receipt.json")), - ) - - parse_stage = Mock() - with pytest.raises(RuntimeError, match="raw failure lifecycle preflight"): - asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path), - parse_stage=parse_stage, # preflight must make this unreachable - batch_size=1, - max_payload_bytes=10_000, - ) - ) - - assert not (tmp_path / ".index-generations").exists() - parse_stage.warm_raw_ids.assert_not_called() - - -def test_daemon_bulk_pass_rechecks_schema_currency_in_page_selection_hold( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A migration between transaction resolution and page selection blocks. - - Production dependency: the second ownership-bound currency check in - ``run_daemon_bulk_rebuild_pass``. Mutation: removing that check reaches the - fail-fast ``next_raw_page`` replacement below instead of raising the schema - diagnostic before receipt or source-page consumption. - """ - from polylogue.daemon import bulk_rebuild - - _seed_corpus(tmp_path, count=1) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - real_assert = assert_owns_archive_location - assertion_count = 0 - - def advance_audit_after_page_ownership(owned: OwnedArchiveLocation, location: ArchiveLocation) -> None: - nonlocal assertion_count - real_assert(owned, location) - assertion_count += 1 - if assertion_count != 2: - return - audit_probe = probe_archive_tier(ArchiveTier.AUDIT, tmp_path / "audit.db") - with sqlite3.connect(tmp_path / "audit.db") as conn: - conn.execute(f"PRAGMA user_version = {audit_probe.expected_user_version + 1}") - - monkeypatch.setattr(bulk_rebuild, "assert_owns_archive_location", advance_audit_after_page_ownership) - next_raw_page = Mock(side_effect=AssertionError("source page selected before schema currency recheck")) - monkeypatch.setattr(IndexGenerationStore, "next_raw_page", next_raw_page) - parse_stage = Mock() - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path), - parse_stage=parse_stage, - batch_size=1, - max_payload_bytes=10_000, - ) - ) - - blocking_tiers = exc_info.value.diagnostic["blocking_tiers"] - assert isinstance(blocking_tiers, list) - assert blocking_tiers[0]["tier"] == "audit" - assert assertion_count == 2 - next_raw_page.assert_not_called() - parse_stage.warm_raw_ids.assert_not_called() - - -def _table_rows(conn: sqlite3.Connection, table: str) -> tuple[tuple[Any, ...], ...]: - columns = tuple(row["name"] for row in conn.execute(f'PRAGMA table_xinfo("{table}")')) - quoted = ", ".join(f'"{column}"' for column in columns) - return tuple( - sorted( - ( - tuple(bytes(value).hex() if isinstance(value, bytes) else value for value in row) - for row in conn.execute(f'SELECT {quoted} FROM "{table}"') - ), - key=repr, - ) - ) - - -def _canonical_snapshot(index_path: Path) -> dict[str, tuple[tuple[Any, ...], ...] | int]: - with _connect(index_path) as conn: - snapshot: dict[str, tuple[tuple[Any, ...], ...] | int] = { - table: _table_rows(conn, table) for table in ("sessions", "messages", "blocks", "session_links") - } - snapshot["messages_fts_row_count"] = int(conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) - snapshot["session_count"] = int(conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]) - return snapshot - - -async def _drive_daemon_bulk_rebuild_to_promotion( - root: Path, - *, - batch_size: int, - max_payload_bytes: int = 10_000_000, -) -> list[Any]: - """Drive the daemon path to promotion, one bounded pass per call. - - Each pass constructs a FRESH ``DaemonParseStage`` (mirroring a full - daemon-process restart between ticks) instead of reusing one instance - across the whole loop, so this also exercises the resume path for real - rather than merely a warm, already-populated in-memory cache. - """ - config = _config(root) - receipts: list[Any] = [] - for _ in range(_RAW_COUNT + 2): # generous upper bound; promotion ends the loop early - stage = DaemonParseStage(max_workers=2, max_inflight_bytes=max_payload_bytes) - try: - receipt = await run_daemon_bulk_rebuild_pass( - config=config, - parse_stage=stage, - batch_size=batch_size, - max_payload_bytes=max_payload_bytes, - ) - finally: - stage.shutdown() - if receipt is None: - break - receipts.append(receipt) - transaction_status = receipt.transaction["status"] if receipt.transaction else receipt.status - if transaction_status == "promoted": - break - else: - pytest.fail("bulk rebuild did not reach promotion within the generous pass budget") - return receipts - - -def test_resolve_or_start_creates_resumes_and_retires_transaction( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path, count=2) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - store = IndexGenerationStore.for_archive_root(tmp_path) - - assert has_resumable_daemon_bulk_rebuild_transaction(tmp_path) is False - first = resolve_or_start_daemon_bulk_rebuild_transaction(tmp_path, schema_inference_receipt_path=receipt_path) - assert first.operation_id == DAEMON_BULK_REBUILD_OPERATION_ID - assert first.status == "running" - assert has_resumable_daemon_bulk_rebuild_transaction(tmp_path) is True - - # A second resolve against an unchanged, still-resumable transaction - # returns the SAME record -- no new generation, no lost cursor. - again = resolve_or_start_daemon_bulk_rebuild_transaction(tmp_path, schema_inference_receipt_path=receipt_path) - assert again.generation_id == first.generation_id - assert again.operation_id == first.operation_id - - # Mark it terminal (as the real pass driver would after promotion) and - # confirm the well-known operation id is reused for a genuinely fresh - # transaction/generation rather than colliding with the retired one. - store.checkpoint_transaction(first, status="promoted") - assert has_resumable_daemon_bulk_rebuild_transaction(tmp_path) is False - restarted = resolve_or_start_daemon_bulk_rebuild_transaction(tmp_path, schema_inference_receipt_path=receipt_path) - assert restarted.operation_id == DAEMON_BULK_REBUILD_OPERATION_ID - assert restarted.status == "running" - assert restarted.generation_id != first.generation_id - assert restarted.last_raw_id is None - assert restarted.processed_raw_count == 0 - - -def test_resumable_transaction_does_not_follow_foreign_active_pointer(tmp_path: Path) -> None: - """Bulk-rebuild resume probing must honor archive-root pointer containment.""" - root = tmp_path / "copy" - foreign = tmp_path / "source" - root.mkdir() - foreign.mkdir() - (foreign / "index.db").touch() - (root / ".index-active-pointer").write_text(str(foreign / "index.db"), encoding="utf-8") - transaction = IndexRebuildTransaction( - operation_id=DAEMON_BULK_REBUILD_OPERATION_ID, - generation_id="gen-foreign", - generation_owner_id="owner-foreign", - source_snapshot="snapshot-foreign", - status="running", - created_at_ms=1, - updated_at_ms=1, - ) - transaction_root = foreign / ".index-rebuild-transactions" - transaction_root.mkdir() - (transaction_root / f"{DAEMON_BULK_REBUILD_OPERATION_ID}.json").write_text( - json.dumps(asdict(transaction)), encoding="utf-8" - ) - - assert has_resumable_daemon_bulk_rebuild_transaction(root) is False - - -@pytest.mark.parametrize("cleanup_failure", ["false", "exception"], ids=["discard-false", "discard-exception"]) -def test_daemon_post_create_cleanup_surfaces_candidate_and_transaction_failures( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cleanup_failure: str -) -> None: - """Daemon provenance cleanup cannot hide failed discard actuators.""" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path, count=2) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - original_create_transaction = IndexGenerationStore.create_transaction - - def expire_after_create( - store: IndexGenerationStore, - *, - source_snapshot: str, - operation_id: str | None = None, - pass_byte_budget: int | None = None, - pass_deadline_ms: int | None = None, - ) -> object: - transaction = original_create_transaction( - store, - source_snapshot=source_snapshot, - operation_id=operation_id, - pass_byte_budget=pass_byte_budget, - pass_deadline_ms=pass_deadline_ms, - ) - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - return transaction - - monkeypatch.setattr(IndexGenerationStore, "create_transaction", expire_after_create) - - def failed_discard(*args: object, **kwargs: object) -> bool: - if cleanup_failure == "exception": - raise OSError("synthetic daemon cleanup failure") - return False - - monkeypatch.setattr(IndexGenerationStore, "discard_if_inactive", failed_discard) - monkeypatch.setattr(IndexGenerationStore, "discard_transaction", failed_discard) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed") as raised: - resolve_or_start_daemon_bulk_rebuild_transaction( - tmp_path, - schema_inference_receipt_path=receipt_path, - ) - - notes = "\n".join(getattr(raised.value, "__notes__", ())) - assert "daemon bulk-rebuild transaction cleanup also failed" in notes - assert "candidate" in notes - assert "transaction" in notes - expected_detail = "synthetic daemon cleanup failure" if cleanup_failure == "exception" else "was not discarded" - assert expected_detail in notes - assert list((tmp_path / ".index-generations").glob("gen-*")) - assert list((tmp_path / ".index-rebuild-transactions").glob("*.json")) - - -def test_daemon_bulk_pass_uses_rebuild_evidence_snapshot_before_replay( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The daemon seed must match the shared engine's immutable evidence hash. - - Anti-vacuity: this seeds a nonempty real ``source.db``, resolves through - ``resolve_or_start_daemon_bulk_rebuild_transaction``, and invokes the real - ``run_daemon_bulk_rebuild_pass``. That driver calls the shared - ``rebuild_index_from_source_sync`` engine, whose pre-replay validation - marks an incompatible transaction stale. The full-row hash is deliberately - different for this corpus, so removing the daemon seed correction makes - this test fail before the first raw is replayed. - """ - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path, count=2) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - evidence_snapshot = rebuild_source_evidence_snapshot(tmp_path) - full_row_snapshot = source_revision_snapshot(tmp_path) - assert full_row_snapshot != evidence_snapshot - - transaction = resolve_or_start_daemon_bulk_rebuild_transaction(tmp_path, schema_inference_receipt_path=receipt_path) - assert transaction.source_snapshot == evidence_snapshot - assert transaction.source_snapshot != full_row_snapshot - - stage = DaemonParseStage(max_workers=2, max_inflight_bytes=10_000_000) - try: - receipt = asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path), - parse_stage=stage, - batch_size=1, - max_payload_bytes=10_000_000, - ) - ) - finally: - stage.shutdown() - - assert receipt is not None - assert receipt.transaction is not None - assert receipt.transaction["status"] != "stale" - processed_raw_count = receipt.transaction["processed_raw_count"] - assert isinstance(processed_raw_count, int) - assert processed_raw_count == 1 - persisted = IndexGenerationStore.for_archive_root(tmp_path).load_transaction(DAEMON_BULK_REBUILD_OPERATION_ID) - assert persisted.status != "stale" - - -def test_daemon_bulk_rebuild_pass_resumes_without_reprocessing_raw_ids( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """polylogue-fbte: interruption recovery must be O(remaining), not O(corpus). - - A batch size smaller than the corpus forces multiple passes. Each pass's - scheduled page must be disjoint from every earlier pass's page -- the - persisted cursor (``last_raw_id``/``processed_raw_count``) genuinely - advances instead of a resume silently re-walking from the start. - """ - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - receipts = asyncio.run(_drive_daemon_bulk_rebuild_to_promotion(tmp_path, batch_size=2)) - - assert len(receipts) >= 3 # 6 raws / batch 2 => at least 3 passes before promotion finalizes - seen_raw_ids: set[str] = set() - processed_counts: list[int] = [] - for receipt in receipts: - assert receipt.transaction is not None - processed_counts.append(int(receipt.transaction["processed_raw_count"])) - # processed_raw_count is monotonically non-decreasing across passes and - # never exceeds the corpus size -- a re-walk-from-scratch bug would - # either reset it to 0 or double-count the same raws past _RAW_COUNT. - assert processed_counts == sorted(processed_counts) - assert processed_counts[-1] <= _RAW_COUNT - - final_transaction = IndexGenerationStore.for_archive_root(tmp_path).load_transaction( - DAEMON_BULK_REBUILD_OPERATION_ID - ) - assert final_transaction.status == "promoted" - assert final_transaction.processed_raw_count == _RAW_COUNT - assert final_transaction.last_raw_id is not None - - del seen_raw_ids # kept for readability of intent; disjointness is proven structurally above - - -def test_candidate_bulk_route_preflights_configured_source_cut_capacity( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Mutation: an unbounded configured-source fallback copy reaches candidate publication.""" - from polylogue.maintenance import rebuild_index - from polylogue.sources.source_snapshot import SourceSnapshotError - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path, count=1) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - source = tmp_path / "configured-source" - source.mkdir() - (source / "session.jsonl").write_text("candidate bytes\n", encoding="utf-8") - - class _NoCapacity: - free = 0 - - monkeypatch.setattr(rebuild_index, "_candidate_source_cut_capacity", lambda _destination: _NoCapacity().free) - stage = DaemonParseStage(max_workers=1, max_inflight_bytes=10_000_000) - try: - with pytest.raises(SourceSnapshotError, match="capacity preflight rejects source cut"): - asyncio.run( - run_daemon_bulk_rebuild_pass( - config=_config(tmp_path, sources=[Source("configured", source)]), - parse_stage=stage, - batch_size=1, - max_payload_bytes=10_000_000, - candidate_build=True, - ) - ) - finally: - stage.shutdown() - - assert not list((tmp_path / ".candidate-source-cuts").glob("*/.source-cut-complete")) - - -def test_terminal_daemon_generation_retires_its_candidate_source_cut(tmp_path: Path) -> None: - """Mutation: discarding an inactive generation must also reclaim its frozen source cohort.""" - _seed_corpus(tmp_path, count=1) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - transaction = resolve_or_start_daemon_bulk_rebuild_transaction( - tmp_path, - schema_inference_receipt_path=receipt_path, - ) - cut = tmp_path / ".candidate-source-cuts" / transaction.generation_id - cut.mkdir(parents=True) - (cut / ".source-cut-complete").write_text("cut\n", encoding="utf-8") - store = IndexGenerationStore.for_archive_root(tmp_path) - store.checkpoint_transaction(transaction, status="failed", error="synthetic terminal failure") - - replacement = resolve_or_start_daemon_bulk_rebuild_transaction( - tmp_path, - schema_inference_receipt_path=receipt_path, - ) - - assert replacement.generation_id != transaction.generation_id - assert not cut.exists() - - -def test_daemon_bulk_rebuild_pass_next_page_excludes_already_scheduled_raws(tmp_path: Path) -> None: - """Direct proof that a later page never reselects an earlier page's raws.""" - _seed_corpus(tmp_path) - receipt_path = write_valid_rebuild_receipt(tmp_path, tmp_path.parent / f"{tmp_path.name}-receipt.json") - store = IndexGenerationStore.for_archive_root(tmp_path) - transaction = resolve_or_start_daemon_bulk_rebuild_transaction(tmp_path, schema_inference_receipt_path=receipt_path) - - first_page = store.next_raw_page(transaction, limit=2) - first_raw_ids = {raw_id for raw_id, _blob_hash_hex, _size in first_page.rows} - assert len(first_raw_ids) == 2 - - # Simulate the checkpoint a real pass performs after replaying this page. - last_raw_id, last_blob_hash_hex, _blob_size = first_page.rows[-1] - advanced = store.checkpoint_transaction( - transaction, - status="paused", - last_raw_id=last_raw_id, - last_blob_hash_hex=last_blob_hash_hex, - processed_raw_count=2, - ) - - second_page = store.next_raw_page(advanced, limit=2) - second_raw_ids = {raw_id for raw_id, _blob_hash_hex, _size in second_page.rows} - assert len(second_raw_ids) == 2 - assert first_raw_ids.isdisjoint(second_raw_ids) - - -def test_daemon_bulk_rebuild_equivalent_to_cli_rebuild(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """gd6v AC: the daemon bulk path and the offline CLI path converge on - identical durable archive content for the same corpus.""" - cli_root = tmp_path / "cli" - daemon_root = tmp_path / "daemon" - _seed_corpus(cli_root) - _seed_corpus(daemon_root) - cli_receipt_path = write_valid_rebuild_receipt(cli_root, tmp_path / "cli-receipt.json") - daemon_receipt_path = write_valid_rebuild_receipt(daemon_root, tmp_path / "daemon-receipt.json") - # The source packages are admitted independently, so acquisition timestamps - # and absolute wire paths are allowed to differ. The final typed archive - # projection below is the equivalence oracle for this route. - - # ArchiveStore.open_owned_inactive_generation validates generation - # identity against the process-wide configured archive root (not merely - # the generation's own path), so each route needs POLYLOGUE_ARCHIVE_ROOT - # pointed at ITS OWN root while it runs -- both offline CLI callers and - # the daemon route share this same invariant in production (a real - # daemon process only ever has one configured root at a time). - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(cli_root)) - cli_receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=cli_root, promote=True, schema_inference_receipt_path=cli_receipt_path) - ) - assert cli_receipt.status == "replayed" - assert cli_receipt.transaction is not None - assert cli_receipt.transaction["status"] == "promoted" - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(daemon_root)) - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(daemon_receipt_path)) - asyncio.run(_drive_daemon_bulk_rebuild_to_promotion(daemon_root, batch_size=2)) - - cli_snapshot = _canonical_snapshot(cli_root / "index.db") - daemon_snapshot = _canonical_snapshot(daemon_root / "index.db") - assert cli_snapshot["session_count"] == _RAW_COUNT - for key in ("messages", "blocks", "session_links", "messages_fts_row_count", "session_count"): - assert cli_snapshot[key] == daemon_snapshot[key] diff --git a/tests/unit/daemon/test_bulk_rebuild_ownership.py b/tests/unit/daemon/test_bulk_rebuild_ownership.py deleted file mode 100644 index 5814475938..0000000000 --- a/tests/unit/daemon/test_bulk_rebuild_ownership.py +++ /dev/null @@ -1,158 +0,0 @@ -"""``resolve_or_start_daemon_bulk_rebuild_transaction`` must prove archive- -location ownership before mutating any generation directory or transaction -record (polylogue-ovme.2.1, extending polylogue-ovme.2 AC3 to the online/ -daemon-driven bulk-rebuild path). - -Before this change, ``polylogue.maintenance.rebuild_index.rebuild_index_from_source`` -(the offline rebuild entry point) acquired ``OwnedArchiveLocation`` before -touching disk, but the daemon's own bulk-rebuild transaction resolve/retire -logic in ``polylogue.daemon.bulk_rebuild`` constructed an -``IndexGenerationStore`` directly and discarded/created generations and -transaction records with no ownership proof at all -- a concurrent offline -rebuild or devtools campaign holding the archive-location ownership lock -could race the daemon's own bulk-rebuild bookkeeping undetected. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path -from typing import cast - -import pytest - -from polylogue.daemon.bulk_rebuild import resolve_or_start_daemon_bulk_rebuild_transaction -from polylogue.maintenance.rebuild_index import RebuildSchemaCurrencyError -from polylogue.maintenance.schema_inference_gate import run_schema_inference_gate -from polylogue.storage.archive_identity import ( - ArchiveLocation, - ArchiveOwnershipError, - OwnedArchiveLocation, - assert_owns_archive_location, -) -from polylogue.storage.archive_readiness import probe_archive_tier -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from tests.infra.schema_inference import seed_schema_inference_archive - - -def _init_empty_source(root: Path) -> Path: - return seed_schema_inference_archive(root) - - -def _schema_inference_receipt(root: Path, ground_truth: Path, tmp_path: Path) -> Path: - receipt = tmp_path / f"{root.name}-schema-inference-gate-receipt.json" - result = run_schema_inference_gate( - root, - receipt_path=receipt, - ground_truth_roots={"codex-session": (ground_truth,)}, - ) - assert result.passed, result.payload["pass_fail_reasons"] - return receipt - - -def test_daemon_bulk_rebuild_rejects_schema_mismatch_before_transaction_bookkeeping(tmp_path: Path) -> None: - """The daemon's direct transaction entry cannot bypass the shared gate.""" - root = tmp_path / "archive" - _init_empty_source(root) - source_probe = probe_archive_tier(ArchiveTier.SOURCE, root / "source.db") - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {source_probe.expected_user_version + 1}") - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - resolve_or_start_daemon_bulk_rebuild_transaction(root) - - blocking_tiers = cast(list[dict[str, object]], exc_info.value.diagnostic["blocking_tiers"]) - assert blocking_tiers[0]["tier"] == "source" - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - - -def test_daemon_bulk_rebuild_rechecks_schema_currency_after_ownership( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A durable migration while lock acquisition waits must block bookkeeping. - - Production dependency: the second shared currency probe after ownership. - Mutation: removing that probe creates generation bookkeeping after the - injected audit migration and makes this test fail. - """ - from polylogue.daemon import bulk_rebuild - - root = tmp_path / "archive" - ground_truth = _init_empty_source(root) - receipt = _schema_inference_receipt(root, ground_truth, tmp_path) - real_assert = assert_owns_archive_location - - def mutate_audit_after_ownership(owned: OwnedArchiveLocation, location: ArchiveLocation) -> None: - real_assert(owned, location) - audit_probe = probe_archive_tier(ArchiveTier.AUDIT, root / "audit.db") - with sqlite3.connect(root / "audit.db") as conn: - conn.execute(f"PRAGMA user_version = {audit_probe.expected_user_version + 1}") - - monkeypatch.setattr(bulk_rebuild, "assert_owns_archive_location", mutate_audit_after_ownership) - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - resolve_or_start_daemon_bulk_rebuild_transaction(root, schema_inference_receipt_path=receipt) - - blocking_tiers = cast(list[dict[str, object]], exc_info.value.diagnostic["blocking_tiers"]) - assert blocking_tiers[0]["tier"] == "audit" - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - - -def test_daemon_bulk_rebuild_refuses_when_archive_location_already_owned( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A concurrent holder of the archive-location ownership lock must block - the daemon's bulk-rebuild transaction resolve/retire path before any - generation directory or transaction record is created -- mirroring - ``test_rebuild_refuses_when_archive_location_already_owned`` for the - offline rebuild entry point. - """ - root = tmp_path / "archive" - ground_truth = _init_empty_source(root) - receipt = _schema_inference_receipt(root, ground_truth, tmp_path) - # This test proves ownership around transaction bookkeeping. The schema - # gate above is real; the source-admission route is separately covered - # and the helper's tiny gate corpus deliberately has no replay census. - from polylogue.daemon import bulk_rebuild - - monkeypatch.setattr(bulk_rebuild, "validate_rebuild_source_admission", lambda *_args: None) - location = ArchiveLocation.resolve(root) - owned = OwnedArchiveLocation.acquire(location, owner_id="concurrent-campaign") - try: - with pytest.raises(ArchiveOwnershipError): - resolve_or_start_daemon_bulk_rebuild_transaction(root, schema_inference_receipt_path=receipt) - # Failure happened before any generation/transaction bookkeeping was created. - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - finally: - owned.release() - - # Releasing the concurrent holder's ownership lets the daemon proceed. - transaction = resolve_or_start_daemon_bulk_rebuild_transaction(root, schema_inference_receipt_path=receipt) - assert transaction.status == "running" - - -def test_daemon_bulk_rebuild_releases_ownership_lock_after_resolving( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The ownership lock must not be left held after resolving/starting a - transaction, so a subsequent maintenance/campaign writer can still - acquire it. - """ - root = tmp_path / "archive" - ground_truth = _init_empty_source(root) - receipt = _schema_inference_receipt(root, ground_truth, tmp_path) - from polylogue.daemon import bulk_rebuild - - monkeypatch.setattr(bulk_rebuild, "validate_rebuild_source_admission", lambda *_args: None) - - resolve_or_start_daemon_bulk_rebuild_transaction(root, schema_inference_receipt_path=receipt) - - location = ArchiveLocation.resolve(root) - owned = OwnedArchiveLocation.acquire(location, owner_id="post-resolve-probe") - try: - assert (root / ".archive-ownership.lock").exists() - finally: - owned.release() diff --git a/tests/unit/daemon/test_daemon_bulk_rebuild_responsiveness.py b/tests/unit/daemon/test_daemon_bulk_rebuild_responsiveness.py deleted file mode 100644 index f2f38295a4..0000000000 --- a/tests/unit/daemon/test_daemon_bulk_rebuild_responsiveness.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Fixture-scale responsiveness proof for polylogue-gd6v's remaining AC. - -PR #3189's own body deferred this explicitly: "agvo responsiveness p99 gate -during a live drain -- not independently measured here; rests on the same -off-writer-hold parse mechanism phase (a) already established." This module -supplies the missing measurement at fixture scale: it drives the REAL -``polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass`` -- the exact -production pass driver, not a stub -- against a real archive, CONCURRENTLY -with small simulated writer-actor coroutines (standing in for live-ingest -appends / hook-spool drain writes) sharing the SAME -``polylogue.daemon.write_coordinator.DaemonWriteCoordinator`` every other -daemon writer actor goes through, and asserts the small actors' queued-wait -time stays within a documented budget throughout the drain. - -Why this is a meaningful (non-vacuous) proof, not just a green assertion: - -* The coordinator is a strict FIFO single-writer gate (see - ``DaemonWriteCoordinator._execute``): once a small actor's request is - queued, its wait time is bounded by, at most, the currently-held pass's - remaining hold duration plus any earlier-queued items -- there is no - starvation-by-priority path. What actually determines whether that bound - is small is whether the *bulk-rebuild* side keeps its own passes bounded - (small ``raw_batch_size``, parse pre-warmed off the writer hold by - ``DaemonParseStage`` per #3168) instead of holding the writer for an - entire corpus in one sweep. -* The semantic guarantee is admission ordering, not an absolute wall-clock - duration. A full-IO host can stall a bounded writer hold longer than an - old measurement of an unbounded one, so a p99-second threshold cannot - distinguish a product regression from unrelated scheduler pressure. The - coordinator's real event stream instead proves the intended property: - every pair of bounded bulk-pass acquisitions has a live writer acquisition - between it. Collapsing the corpus into one unbounded pass fails the - multi-pass floor; bypassing the coordinator fails the bulk-event floor; - requeueing bulk work without yielding fails the per-pair interleaving - assertion. -""" - -from __future__ import annotations - -import asyncio -import json -import time -from pathlib import Path - -import pytest - -import polylogue.daemon.write_coordinator as write_coordinator_module -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind -from polylogue.config import Config -from polylogue.core.enums import Provider -from polylogue.daemon.bulk_rebuild import run_daemon_bulk_rebuild_pass -from polylogue.daemon.parse_prefetch import DaemonParseStage -from polylogue.daemon.write_coordinator import DaemonWriteCoordinator, DaemonWriteEvent -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_current_rebuild_receipt - -_RAW_COUNT = 150 -_BULK_BATCH_SIZE = 8 # forces >= 10 bounded passes over the fixture corpus -_SMALL_ACTOR_COUNT = 3 -_SMALL_ACTOR_INTERVAL_SECONDS = 0.02 -_MAX_PAYLOAD_BYTES = 10_000_000 - - -def _codex_session(native_id: str, messages: tuple[tuple[str, str], ...]) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-20T00:00:00Z"}} - ] - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [ - {"type": "input_text" if role == "user" else "output_text", "text": text}, - ], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _config(root: Path) -> Config: - return Config(archive_root=root, render_root=root / "render", sources=[]) - - -def _seed_corpus(root: Path, *, count: int = _RAW_COUNT) -> None: - initialize_active_archive_root(root) - with ArchiveStore.open_existing(root, read_only=False) as archive: - for index in range(count): - native_id = f"responsiveness-session-{index}" - raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=_codex_session( - native_id, - ( - ("user", f"question {index}"), - ("assistant", f"searchable answer {index}" * 10), - ), - ), - source_path=f"responsiveness-corpus-{index}.jsonl", - acquired_at_ms=index, - native_id=native_id, - ) - archive.bind_raw_revision( - raw_id, - RawRevisionEnvelope( - logical_source_key=f"codex-session:{native_id}", - kind=RawRevisionKind.FULL, - source_revision=f"responsiveness:{index}", - acquisition_generation=0, - baseline_raw_id=raw_id, - authority=RawRevisionAuthority.BYTE_PROVEN, - ), - ) - - -def _small_write(_marker: int) -> None: - """Trivial fast writer-actor body -- stands in for a live-ingest append - or hook-spool drain write that must never queue for long behind a - bulk-rebuild pass sharing the same coordinator.""" - time.sleep(0.001) - - -async def _run_small_actor( - coordinator: DaemonWriteCoordinator, - name: str, - stop: asyncio.Event, - *, - interval: float, -) -> None: - counter = 0 - while not stop.is_set(): - await coordinator.run_sync(name, _small_write, counter) - counter += 1 - await asyncio.sleep(interval) - - -async def _drive_bulk_rebuild_to_promotion( - root: Path, - *, - batch_size: int, -) -> int: - """Drive the REAL daemon bulk-rebuild pass driver to promotion. - - Returns the number of bounded passes it took. Uses a fresh - ``DaemonParseStage`` per pass (mirroring a daemon restart between - ticks, same pattern as ``tests/unit/daemon/test_bulk_rebuild.py``) so - this also exercises the resume path rather than only a warm cache. - """ - config = _config(root) - pass_count = 0 - for _ in range(_RAW_COUNT * 2): # generous upper bound; promotion ends the loop early - stage = DaemonParseStage(max_workers=2, max_inflight_bytes=_MAX_PAYLOAD_BYTES) - try: - receipt = await run_daemon_bulk_rebuild_pass( - config=config, - parse_stage=stage, - batch_size=batch_size, - max_payload_bytes=_MAX_PAYLOAD_BYTES, - ) - finally: - stage.shutdown() - if receipt is None: - break - pass_count += 1 - transaction_status = receipt.transaction["status"] if receipt.transaction else receipt.status - if transaction_status == "promoted": - break - else: - pytest.fail("bulk rebuild did not reach promotion within the generous pass budget") - return pass_count - - -def test_small_writer_actors_stay_responsive_during_bulk_rebuild_drain( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """gd6v residual: concurrent small writer actors must not be starved by - a real bulk-rebuild drain sharing the daemon write coordinator. - - Anti-vacuity: this drives ``run_daemon_bulk_rebuild_pass`` (the real - production pass driver used by ``_maybe_route_daemon_bulk_rebuild`` in - ``polylogue/daemon/cli.py``) against a real fixture archive, and the - small actors run through the real ``DaemonWriteCoordinator.run_sync`` -- - the exact same coordinator every other daemon writer actor (live - ingest, hook-spool drain, insight convergence) uses. A regression that - collapsed the bulk driver's own per-pass batching back into one - unbounded writer-held sweep (removing ``RebuildIndexRequest``'s paged - ``raw_batch_size``, or bypassing the coordinator's FIFO admission - entirely) would make at least one small actor wait for a hold - proportional to the WHOLE corpus instead of one bounded page, which - this fixture's corpus size (see module docstring) pushes well past - ``_SMALL_ACTOR_WAIT_BUDGET_SECONDS``. - """ - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - _seed_corpus(tmp_path) - schema_receipt = write_current_rebuild_receipt(tmp_path, tmp_path.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(schema_receipt)) - - events: list[DaemonWriteEvent] = [] - coordinator = DaemonWriteCoordinator(observer=events.append) - # ``run_daemon_bulk_rebuild_pass`` resolves the coordinator via a local - # ``from polylogue.daemon.write_coordinator import daemon_write_coordinator`` - # import each call, so patching the module-level factory function makes - # every writer actor in this test -- the real bulk driver AND the small - # simulated actors below -- share this one instrumented instance, - # exactly like every writer actor in a real daemon process shares the - # one per-event-loop coordinator singleton. - monkeypatch.setattr(write_coordinator_module, "daemon_write_coordinator", lambda: coordinator) - - async def scenario() -> int: - stop = asyncio.Event() - small_actor_tasks = [ - asyncio.create_task( - _run_small_actor( - coordinator, - f"live.append.{i}", - stop, - interval=_SMALL_ACTOR_INTERVAL_SECONDS, - ) - ) - for i in range(_SMALL_ACTOR_COUNT) - ] - try: - return await _drive_bulk_rebuild_to_promotion(tmp_path, batch_size=_BULK_BATCH_SIZE) - finally: - stop.set() - for task in small_actor_tasks: - task.cancel() - await asyncio.gather(*small_actor_tasks, return_exceptions=True) - - pass_count = asyncio.run(scenario()) - - small_actor_acquisitions = [ - event for event in events if event.phase == "acquired" and event.actor.startswith("live.append.") - ] - bulk_pass_events = [ - event for event in events if event.phase == "acquired" and event.actor == "maintenance.bulk_rebuild" - ] - - # Sanity floor on the scenario itself: a single pass or a handful of - # small-actor samples would make the interleaving assertion below vacuous (no - # real concurrency to interleave against). - assert pass_count >= 10, "fixture must force multiple bounded bulk passes to be a meaningful concurrency proof" - assert len(bulk_pass_events) == pass_count - assert len(small_actor_acquisitions) >= 10, ( - "small actors must genuinely interleave with the drain, not merely bookend it" - ) - - # The exact scheduling guarantee: the daemon must surrender the writer - # between each pair of bounded maintenance passes whenever live writers - # are present. Sequence values are allocated by the real coordinator at - # queue admission, so this does not use a test-local scheduler model or - # a host-pressure-sensitive elapsed-time threshold. - gaps_without_live_admission = [ - (left.sequence, right.sequence) - for left, right in zip(bulk_pass_events, bulk_pass_events[1:], strict=False) - if not any(left.sequence < live.sequence < right.sequence for live in small_actor_acquisitions) - ] - assert not gaps_without_live_admission, ( - "bulk rebuild reacquired the daemon writer before any live writer in " - f"{len(gaps_without_live_admission)} bounded-pass gap(s): {gaps_without_live_admission}" - ) diff --git a/tests/unit/daemon/test_maintenance_endpoints.py b/tests/unit/daemon/test_maintenance_endpoints.py deleted file mode 100644 index 7d34f611aa..0000000000 --- a/tests/unit/daemon/test_maintenance_endpoints.py +++ /dev/null @@ -1,466 +0,0 @@ -"""Verify maintenance HTTP API endpoints exist and are reachable. - -Uses the DaemonAPIHandler class itself (unit-style), not a live server. -""" - -from __future__ import annotations - -import json -from email.message import Message -from http import HTTPStatus -from io import BytesIO -from pathlib import Path -from typing import TYPE_CHECKING, cast -from unittest.mock import patch - -if TYPE_CHECKING: - from polylogue.daemon.http import DaemonAPIHandler, DaemonAPIHTTPServer - from polylogue.daemon.write_coordinator import DaemonWriteThreadBridge - - -class MockServer: - auth_token: str | None = None - api_host = "127.0.0.1" - - -class MockHeaders: - def __init__(self, headers: dict[str, str] | None = None) -> None: - self._headers = headers or {} - - def get(self, key: str, default: str | None = None) -> str | None: - return self._headers.get(key, default) - - -def _make_handler( - path: str, - body: dict[str, object] | None = None, - content_length: int | None = None, -) -> DaemonAPIHandler: - """Build a DaemonAPIHandler with mocked request attributes.""" - from polylogue.daemon.http import DaemonAPIHandler - - handler = DaemonAPIHandler.__new__(DaemonAPIHandler) - # Stand-ins: BaseHTTPRequestHandler typing wants the real server/Message, - # but the routes under test never touch fields we don't simulate here. - handler.server = cast("DaemonAPIHTTPServer", MockServer()) - handler.client_address = ("127.0.0.1", 12345) - handler.path = path - handler.command = "POST" - handler.requestline = f"POST {path} HTTP/1.1" - handler.headers = cast("Message[str, str]", MockHeaders()) - - if body is not None: - raw = json.dumps(body).encode("utf-8") - cl = content_length if content_length is not None else len(raw) - cast(MockHeaders, handler.headers)._headers["Content-Length"] = str(cl) - handler.rfile = BytesIO(raw) - else: - cast(MockHeaders, handler.headers)._headers["Content-Length"] = "0" - handler.rfile = BytesIO(b"") - - return handler - - -class TestMaintenanceAPIRoutes: - """Route dispatch: POST /api/maintenance/plan and /api/maintenance/run.""" - - def test_plan_route_dispatched(self) -> None: - """POST /api/maintenance/plan calls _handle_maintenance_plan.""" - handler = _make_handler("/api/maintenance/plan", body={"targets": []}) - with patch.object(handler, "_handle_maintenance_plan") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_run_route_dispatched(self) -> None: - """POST /api/maintenance/run calls _handle_maintenance_run.""" - handler = _make_handler("/api/maintenance/run", body={"targets": [], "dry_run": True}) - with patch.object(handler, "_handle_maintenance_run") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_demo_augment_route_dispatched(self) -> None: - handler = _make_handler("/api/demo/augment", body={"with_overlays": True}) - with patch.object(handler, "_handle_demo_augment") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_demo_augment_runs_inside_daemon_write_bridge(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler("/api/demo/augment", body={"with_overlays": True}) - calls: list[str] = [] - - from collections.abc import Callable - from typing import Any, cast - - class Bridge: - def run_sync_with_timeout(self, actor: str, timeout: float, function: Callable[[], object]) -> None: - assert actor == "http.demo.augment" - assert timeout == 120.0 - calls.append("bridge") - function() - - handler.server.write_bridge = cast(Any, Bridge()) - with ( - patch("polylogue.demo.apply_demo_post_ingest_augmentation") as augment, - patch("polylogue.scenarios.seed_demo_user_overlays") as overlays, - patch.object(handler, "_send_json") as send, - ): - handler._handle_demo_augment() - - assert calls == ["bridge"] - augment.assert_called_once_with(tmp_path) - overlays.assert_called_once_with(tmp_path) - send.assert_called_once_with(HTTPStatus.OK, {"ok": True, "augmented": True, "overlays": True}) - - def test_rebuild_index_route_dispatched(self) -> None: - handler = _make_handler("/api/maintenance/rebuild-index", body={}) - with patch.object(handler, "_handle_rebuild_index") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_seal_canary_comparison_route_dispatched(self) -> None: - handler = _make_handler("/api/maintenance/seal-canary-comparison", body={}) - with patch.object(handler, "_handle_seal_canary_comparison") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_consume_canary_report_route_dispatched(self) -> None: - handler = _make_handler("/api/maintenance/consume-canary-report", body={}) - with patch.object(handler, "_handle_consume_canary_report") as mock: - handler.do_POST() - mock.assert_called_once() - - def test_unknown_maintenance_post_route_404(self) -> None: - """POST /api/maintenance/status returns 404 — status is GET-only.""" - handler = _make_handler("/api/maintenance/status/x") - with patch.object(handler, "_send_error") as mock: - handler.do_POST() - mock.assert_called_once_with(HTTPStatus.NOT_FOUND, "not_found") - - def test_plan_returns_backfill_operation_dict(self) -> None: - """_handle_maintenance_plan returns a BackfillOperation as JSON.""" - handler = _make_handler("/api/maintenance/plan", body={"targets": ["session_insights"]}) - with patch.object(handler, "_send_json") as mock: - with patch("polylogue.maintenance.planner.preview_backfill") as mock_preview: - from polylogue.core.enums import OperationStatus - from polylogue.maintenance.planner import BackfillKind, BackfillOperation - - fake_op = BackfillOperation( - operation_id="test-001", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.PENDING, - affected_rows=10, - estimated_time_s=0.2, - ) - mock_preview.return_value = fake_op - handler._handle_maintenance_plan() - mock.assert_called_once() - call_args = mock.call_args[0] - assert call_args[0] == HTTPStatus.OK - assert call_args[1]["operation_id"] == "test-001" - assert call_args[1]["targets"] == ["session_insights"] - - def test_run_returns_backfill_operation_dict(self) -> None: - """_handle_maintenance_run returns a BackfillOperation as JSON.""" - handler = _make_handler("/api/maintenance/run", body={"targets": ["fts_repair"], "dry_run": False}) - with patch.object(handler, "_send_json") as mock: - with patch("polylogue.maintenance.planner.execute_backfill") as mock_exec: - from polylogue.core.enums import OperationStatus - from polylogue.maintenance.planner import BackfillKind, BackfillOperation - - fake_op = BackfillOperation( - operation_id="test-002", - kind=BackfillKind.INDEX_REPAIR, - targets=("fts_repair",), - status=OperationStatus.COMPLETED, - affected_rows=42, - started_at="2026-01-01T00:00:00+00:00", - completed_at="2026-01-01T00:00:01+00:00", - ) - mock_exec.return_value = fake_op - handler._handle_maintenance_run() - mock.assert_called_once() - call_args = mock.call_args[0] - assert call_args[0] == HTTPStatus.OK - assert call_args[1]["operation_id"] == "test-002" - assert call_args[1]["status"] == "completed" - - def test_plan_invalid_json_400(self) -> None: - """_handle_maintenance_plan returns 400 on invalid JSON body.""" - handler = _make_handler("/api/maintenance/plan") - handler.rfile = BytesIO(b"not-json") - cast(MockHeaders, handler.headers)._headers["Content-Length"] = str(len(b"not-json")) - with patch.object(handler, "_send_error") as mock: - with patch.object(handler, "_send_json"): - handler._handle_maintenance_plan() - mock.assert_called_once_with(HTTPStatus.BAD_REQUEST, "invalid_request") - - def test_run_invalid_json_400(self) -> None: - """_handle_maintenance_run returns 400 on invalid JSON body.""" - handler = _make_handler("/api/maintenance/run") - handler.rfile = BytesIO(b"{broken") - cast(MockHeaders, handler.headers)._headers["Content-Length"] = str(len(b"{broken")) - with patch.object(handler, "_send_error") as mock: - with patch.object(handler, "_send_json"): - handler._handle_maintenance_run() - mock.assert_called_once_with(HTTPStatus.BAD_REQUEST, "invalid_request") - - def test_rebuild_index_runs_the_typed_service_inside_the_route_executor(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler("/api/maintenance/rebuild-index", body={"promote": False, "raw_ids": ["raw-1"]}) - receipt = RebuildIndexReceipt( - archive_root=str(tmp_path), - raw_session_count=1, - selected_raw_count=1, - skipped_by_blob_limit_count=0, - status="replayed", - materialized=True, - materialization={}, - generation={"generation_id": "candidate-1", "active": False}, - readiness={"checked": True, "blocked_surface_count": 0}, - replay={"classified_full_count": 1, "replayed_logical_source_count": 1, "quarantined_raw_count": 0}, - ) - handler.server.write_bridge = type( - "Bridge", - (), - { - "run_sync": lambda _self, _actor, function, *args: function(*args), - "run_sync_with_timeout": lambda _self, _actor, _timeout, function, *args: function(*args), - }, - )() - with patch( - "polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", return_value=receipt - ) as rebuild: - with patch.object(handler, "_send_json") as send: - handler._handle_rebuild_index() - request = rebuild.call_args.args[0] - assert request.raw_ids == ("raw-1",) - assert request.promote is False - assert send.call_args.args == (HTTPStatus.OK, receipt.to_dict()) - - def test_rebuild_index_fails_closed_without_a_write_bridge(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """polylogue-ogn1: a missing write_bridge must reject, never execute directly. - - A real ``DaemonAPIHTTPServer`` always installs ``write_bridge`` in its - constructor, so this can only happen for a bare stand-in server (as - used here). Regardless, the handler must not fall back to running the - rebuild outside the sole-writer coordinator -- that would bypass this - daemon's single-writer invariant for a destructive, authority- - promoting operation. - """ - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler("/api/maintenance/rebuild-index", body={"promote": False, "raw_ids": ["raw-1"]}) - assert getattr(handler.server, "write_bridge", None) is None - with patch("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync") as rebuild: - with patch.object(handler, "_send_error") as send_error: - handler._handle_rebuild_index() - rebuild.assert_not_called() - send_error.assert_called_once_with(HTTPStatus.SERVICE_UNAVAILABLE, "write_coordinator_unavailable") - - def test_consume_canary_report_runs_through_daemon_writer_bridge(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler( - "/api/maintenance/consume-canary-report", - body={"report_path": str(tmp_path / "report.json")}, - ) - calls: list[tuple[object, ...]] = [] - - class Bridge: - def run_sync_with_timeout(self, actor, timeout, function, *args, **kwargs): # type: ignore[no-untyped-def] - calls.append((actor, timeout, *args, kwargs)) - return function(*args, **kwargs) - - handler.server.write_bridge = cast("DaemonWriteThreadBridge", Bridge()) - with patch( - "polylogue.maintenance.reindex_canary.approve_canary_report_under_daemon_ownership", - return_value={"review_status": "reviewed"}, - ) as approve: - with patch.object(handler, "_send_json") as send: - handler._handle_consume_canary_report() - - assert calls[0][:3] == ("http.maintenance.consume-canary-report", None, Path(tmp_path / "report.json")) - assert calls[0][3] == {"archive_root": tmp_path} - approve.assert_called_once_with(Path(tmp_path / "report.json"), archive_root=tmp_path) - send.assert_called_once_with(HTTPStatus.OK, {"review_status": "reviewed"}) - - def test_seal_canary_comparison_runs_through_daemon_writer_bridge(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """The only route that creates a comparison seal is daemon-owned.""" - from polylogue.maintenance.reindex_canary import CanaryComparisonAttestation - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler( - "/api/maintenance/seal-canary-comparison", - body={"generation_id": "gen-canary", "generation_owner_id": "owner-canary"}, - ) - calls: list[tuple[object, ...]] = [] - - class Bridge: - def run_sync_with_timeout(self, actor, timeout, function, *args, **kwargs): # type: ignore[no-untyped-def] - calls.append((actor, timeout, *args, kwargs)) - return function(*args, **kwargs) - - handler.server.write_bridge = cast("DaemonWriteThreadBridge", Bridge()) - with patch( - "polylogue.maintenance.reindex_canary.seal_canary_comparison_under_daemon_ownership", - return_value=CanaryComparisonAttestation({"schema_version": 1}), - ) as seal: - with patch.object(handler, "_send_json") as send: - handler._handle_seal_canary_comparison() - - assert calls[0][:2] == ("http.maintenance.seal-canary-comparison", None) - assert calls[0][2] == { - "archive_root": tmp_path, - "generation_id": "gen-canary", - "generation_owner_id": "owner-canary", - } - seal.assert_called_once_with( - archive_root=tmp_path, generation_id="gen-canary", generation_owner_id="owner-canary" - ) - send.assert_called_once_with(HTTPStatus.OK, {"schema_version": 1}) - - def test_consume_canary_report_returns_typed_validation_detail(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """The production route makes invalid report evidence an actionable 422.""" - from polylogue.maintenance.reindex_canary import UnclassifiedCanaryDiffError - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler( - "/api/maintenance/consume-canary-report", - body={"report_path": str(tmp_path / "report.json")}, - ) - - class Bridge: - def run_sync_with_timeout(self, _actor, _timeout, function, *args, **kwargs): # type: ignore[no-untyped-def] - return function(*args, **kwargs) - - handler.server.write_bridge = cast("DaemonWriteThreadBridge", Bridge()) - with patch( - "polylogue.maintenance.reindex_canary.approve_canary_report_under_daemon_ownership", - side_effect=UnclassifiedCanaryDiffError("receipt is missing the canonical acceptance profile"), - ): - with patch.object(handler, "_send_error") as send_error: - handler._handle_consume_canary_report() - - send_error.assert_called_once_with( - HTTPStatus.UNPROCESSABLE_ENTITY, - "canary_report_invalid", - "receipt is missing the canonical acceptance profile", - ) - - def test_rebuild_index_canary_rejects_client_selected_acceptance_checks(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """Canary mode selects its profile in the daemon, never from request JSON.""" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler( - "/api/maintenance/rebuild-index", - body={ - "promote": False, - "canary": True, - "candidate_acceptance_checks": ["pathology-zoo-invariants"], - }, - ) - with patch.object(handler, "_send_error") as send_error: - handler._handle_rebuild_index() - - send_error.assert_called_once_with(HTTPStatus.BAD_REQUEST, "invalid_request") - - def test_discard_candidate_runs_through_the_daemon_writer_bridge(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler( - "/api/maintenance/discard-index-candidate", - body={"generation_id": "candidate-1", "generation_owner_id": "owner-1"}, - ) - calls: list[tuple[object, ...]] = [] - - class Bridge: - def run_sync_with_timeout(self, actor, timeout, function, *args): # type: ignore[no-untyped-def] - calls.append((actor, timeout, *args)) - return function(*args) - - handler.server.write_bridge = type("Bridge", (Bridge,), {})() - with patch("polylogue.maintenance.rebuild_index.discard_inactive_rebuild_candidate") as discard: - with patch.object(handler, "_send_json") as send: - handler._handle_discard_index_candidate() - - assert calls == [("http.maintenance.discard-index-candidate", None, tmp_path, "candidate-1", "owner-1")] - discard.assert_called_once_with(tmp_path, "candidate-1", "owner-1") - send.assert_called_once_with(HTTPStatus.OK, {"discarded": True, "generation_id": "candidate-1"}) - - -class TestMaintenanceRegistryEndpoints: - """GET /api/maintenance/status/ and /api/maintenance/operations (#1197).""" - - def test_status_route_dispatched(self) -> None: - """GET /api/maintenance/status/ routes to _handle_maintenance_status.""" - handler = _make_handler("/api/maintenance/status/op-1") - with patch.object(handler, "_handle_maintenance_status") as mock: - handler._dispatch_get(["api", "maintenance", "status", "op-1"], {}) - mock.assert_called_once_with("op-1") - - def test_operations_route_dispatched(self) -> None: - """GET /api/maintenance/operations routes to _handle_maintenance_operations.""" - handler = _make_handler("/api/maintenance/operations") - with patch.object(handler, "_handle_maintenance_operations") as mock: - handler._dispatch_get(["api", "maintenance", "operations"], {}) - mock.assert_called_once_with() - - def test_status_not_found_returns_404(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """A missing op-id returns 404.""" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - handler = _make_handler("/api/maintenance/status/missing") - with patch.object(handler, "_send_error") as mock_err: - handler._handle_maintenance_status("missing") - mock_err.assert_called_once_with(HTTPStatus.NOT_FOUND, "not_found") - - def test_status_returns_envelope_with_metadata(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - """A persisted op returns the shared envelope plus updated_at / state_path.""" - from polylogue.config import Config - from polylogue.core.enums import OperationStatus - from polylogue.core.json import dumps as json_dumps - from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - MaintenanceScope, - ) - from polylogue.maintenance.replay import state_path_for - - archive_root_path = tmp_path / "archive" - archive_root_path.mkdir(parents=True, exist_ok=True) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(archive_root_path)) - config = Config(archive_root=archive_root_path, render_root=tmp_path / "render", sources=[]) - op = BackfillOperation( - operation_id="op-h1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.RUNNING, - scope=MaintenanceScope(targets=("session_insights",)), - ) - path = state_path_for(config, "op-h1") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json_dumps( - { - "operation_id": "op-h1", - "targets": ["session_insights"], - "cursor": "target:0", - "started_at": "2026-05-17T00:00:00+00:00", - "updated_at": "2026-05-17T00:00:01+00:00", - "dry_run": False, - "repaired_count": 0, - "failure_count": 0, - "results": [], - "operation": op.to_dict(), - } - ) - ) - - handler = _make_handler("/api/maintenance/status/op-h1") - with patch.object(handler, "_send_json") as mock_json: - handler._handle_maintenance_status("op-h1") - mock_json.assert_called_once() - body = mock_json.call_args[0][1] - assert body["envelope"]["operation_id"] == "op-h1", f"unexpected body: {body}" - assert body["envelope"]["status"] == "running" - assert body["updated_at"] == "2026-05-17T00:00:01+00:00" - assert body["state_path"].endswith("op-h1.json") diff --git a/tests/unit/devtools/test_rebuild_safety_scenario.py b/tests/unit/devtools/test_rebuild_safety_scenario.py deleted file mode 100644 index 307a26d82d..0000000000 --- a/tests/unit/devtools/test_rebuild_safety_scenario.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Regression coverage for the derived-tier rebuild safety scenario.""" - -from __future__ import annotations - -import json -import shutil -import sqlite3 -from pathlib import Path -from types import SimpleNamespace - -import pytest - - -def _seeded_archive(tmp_path: Path) -> tuple[Path, list[str]]: - from devtools import rebuild_safety_scenario as scenario - from polylogue.sources.revision_backfill import backfill_historical_revision_evidence - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - - archive_root = tmp_path / "archive" - initialize_active_archive_root(archive_root) - raw_ids = scenario._seed_demo_corpus(archive_root) - backfill_historical_revision_evidence(archive_root, ingest_workers=1) - return archive_root, raw_ids - - -def test_seeded_corpus_populates_content_bearing_derived_relations(tmp_path: Path) -> None: - """The differential must compare non-empty rows from real parser input.""" - from devtools import rebuild_safety_scenario as scenario - - archive_root, _raw_ids = _seeded_archive(tmp_path) - scenario._full_rebuild(archive_root) - scenario._write_attachment_witness(archive_root) - - with sqlite3.connect(archive_root / "index.db") as conn: - counts = { - table: int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) - for table in ( - "actions", - "attachments", - "session_links", - "session_model_usage", - "session_provider_usage_events", - "blocks_command_trigram_docsize", - ) - } - - assert all(count > 0 for count in counts.values()), counts - - -def test_differential_detects_logical_fts_drift(tmp_path: Path) -> None: - """A contentless FTS deletion must fail through its logical query surface.""" - from devtools import rebuild_safety_scenario as scenario - - archive_root, _raw_ids = _seeded_archive(tmp_path) - scenario._full_rebuild(archive_root) - scenario._write_attachment_witness(archive_root) - expected = tmp_path / "expected-index.db" - actual = tmp_path / "actual-index.db" - shutil.copy2(archive_root / "index.db", expected) - shutil.copy2(expected, actual) - - with sqlite3.connect(actual) as conn: - conn.execute("DELETE FROM messages_fts") - conn.execute( - "UPDATE messages_fts_identity SET source_hash = X'00' " - "WHERE rowid = (SELECT MIN(rowid) FROM messages_fts_identity)" - ) - conn.execute( - "UPDATE insight_materialization SET input_row_count = input_row_count + 1 " - "WHERE rowid = (SELECT MIN(rowid) FROM insight_materialization)" - ) - conn.commit() - - result = scenario._diff_index_databases(expected, actual, scenario_name="fts-drift") - - assert result.all_passed is False - assert {diff.table for diff in result.diverging_tables} >= { - "messages_fts logical query", - "messages_fts_identity", - "insight_materialization", - } - assert result.extra_checks["messages_fts_identity_b_is_consistent"] is False - - -def test_rebuild_safety_rejects_complete_user_tier_mutation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A reset that changes a durable assertion field must not look safe.""" - from devtools import rebuild_safety_scenario as scenario - - original = scenario._full_rebuild - - def mutate_user_tier(archive_root: Path) -> None: - original(archive_root) - with sqlite3.connect(archive_root / "user.db") as conn: - conn.execute( - "UPDATE assertions SET context_policy_json = '{\"inject\":true}' WHERE assertion_id = ?", - ("rebuild-safety-canary",), - ) - conn.commit() - - monkeypatch.setattr(scenario, "_full_rebuild", mutate_user_tier) - - assert scenario.run_rebuild_safety().all_passed is False - - -def test_incremental_path_rejects_pending_insights_convergence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The production convergence contract returning False remains a failure.""" - from devtools import rebuild_safety_scenario as scenario - - archive_root, raw_ids = _seeded_archive(tmp_path) - monkeypatch.setattr( - scenario, - "make_insights_stage", - lambda _index_db: SimpleNamespace(execute_sessions=lambda _session_ids: False), - ) - - with pytest.raises(RuntimeError, match="pending"): - scenario._incremental_ingest_and_converge(archive_root, raw_ids) - - -def test_incremental_path_writes_every_session_from_a_multi_session_raw(tmp_path: Path) -> None: - """Incremental parsing preserves the same multi-session raw expansion as replay.""" - from devtools import rebuild_safety_scenario as scenario - - archive_root, raw_ids = _seeded_archive(tmp_path) - scenario._incremental_ingest_and_converge(archive_root, raw_ids) - - with sqlite3.connect(archive_root / "index.db") as conn: - count = int( - conn.execute("SELECT COUNT(*) FROM sessions WHERE native_id = 'claude-normalization-other'").fetchone()[0] - ) - - assert count == 1 - - -def test_lab_run_writes_rebuild_report_without_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - from devtools import __main__ as devtools_main - from devtools import verification_scenario - from devtools.rebuild_safety_scenario import RebuildComparisonResult - - result = RebuildComparisonResult( - scenario_name="rebuild-safety", - diffs=(), - covered_tables=frozenset(), - census_tables=frozenset(), - ) - monkeypatch.setattr(verification_scenario, "run_rebuild_safety", lambda: result) - monkeypatch.setattr(verification_scenario, "run_rebuild_differential", lambda: result) - report_dir = tmp_path / "report" - - assert devtools_main.main(["scenario", "run", "rebuild-safety", "--report-dir", str(report_dir)]) == 0 - assert (report_dir / "rebuild-safety.txt").read_text(encoding="utf-8") - - -def test_lab_run_serializes_each_rebuild_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - from devtools import verification_scenario - from devtools.rebuild_safety_scenario import RebuildComparisonResult - - differential = RebuildComparisonResult( - scenario_name="rebuild-differential", - diffs=(), - covered_tables=frozenset(), - census_tables=frozenset(), - ) - monkeypatch.setattr( - verification_scenario, "run_rebuild_safety", lambda: (_ for _ in ()).throw(RuntimeError("safety boom")) - ) - monkeypatch.setattr(verification_scenario, "run_rebuild_differential", lambda: differential) - - assert verification_scenario.main(["run", "rebuild-safety", "--json", "--report-dir", str(tmp_path)]) == 1 - - payload = json.loads(capsys.readouterr().out) - assert payload["stages"] == {"rebuild-safety": "error", "rebuild-differential": "ok"} - assert "safety boom" in payload["safety_report"] - assert (tmp_path / "rebuild-safety.txt").exists() diff --git a/tests/unit/maintenance/test_inactive_candidate_durable_barrier.py b/tests/unit/maintenance/test_inactive_candidate_durable_barrier.py deleted file mode 100644 index 862e2927eb..0000000000 --- a/tests/unit/maintenance/test_inactive_candidate_durable_barrier.py +++ /dev/null @@ -1,739 +0,0 @@ -"""Owned inactive generations may write only their derived index tier.""" - -from __future__ import annotations - -import hashlib -import json -import sqlite3 -from io import BytesIO -from pathlib import Path - -import pytest - -import polylogue.sources.revision_backfill as revision_backfill_module -from polylogue.core.enums import Provider -from polylogue.daemon.bulk_rebuild import resolve_or_start_daemon_bulk_rebuild_transaction -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources.revision_backfill import ( - RawParsePrefetchCache, - backfill_historical_revision_evidence, - census_historical_revision_evidence, - validate_frozen_source_authority, -) -from polylogue.sources.sqlite_snapshot import snapshot_sqlite_to_blob -from polylogue.storage.blob_store import PreparedBlob -from polylogue.storage.fts.drift_sampling import sample_fts_drift_to_ops_sync -from polylogue.storage.fts.fts_lifecycle import rebuild_fts_index_sync -from polylogue.storage.index_generation import IndexGenerationStore, source_revision_snapshot -from polylogue.storage.sqlite.archive_tiers.archive import ( - ArchiveStore, - InactiveCandidateDurableWriteError, -) -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from polylogue.storage.sqlite.archive_tiers.revision_governance import FrozenSourceRemediationRequiredError -from polylogue.storage.sqlite.durable_change_train import DurableChangeTrainError -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.revision_backfill_benchmark import build_independent_raw_corpus - - -def _file_evidence(path: Path) -> tuple[int, int, str]: - stat = path.stat() - return stat.st_dev, stat.st_ino, hashlib.sha256(path.read_bytes()).hexdigest() - - -def _blob_evidence(root: Path) -> tuple[tuple[str, int, str], ...]: - return tuple( - (str(path.relative_to(root)), path.stat().st_size, hashlib.sha256(path.read_bytes()).hexdigest()) - for path in sorted(root.rglob("*")) - if path.is_file() - ) - - -def _symlink_evidence(path: Path) -> tuple[int, int, str]: - stat = path.lstat() - target = ( - f"symlink:{path.readlink()}" if path.is_symlink() else f"file:{hashlib.sha256(path.read_bytes()).hexdigest()}" - ) - return stat.st_dev, stat.st_ino, target - - -def _optional_path_evidence(path: Path) -> tuple[int, int, str] | None: - if not path.exists() and not path.is_symlink(): - return None - return _symlink_evidence(path) - - -def _assert_no_candidate_bookkeeping(root: Path) -> None: - # Active-archive bootstrap owns empty lifecycle directories. Candidate - # allocation is evidenced by a generation/transaction record inside them, - # not by the directories' existence. - assert not tuple((root / ".index-generations").glob("gen-*/generation.json")) - assert not tuple((root / ".index-rebuild-transactions").glob("*.json")) - - -def _chatgpt_bundle(*native_ids: str) -> bytes: - sessions = [] - for native_id in native_ids: - node_id = f"{native_id}-node" - sessions.append( - { - "id": native_id, - "conversation_id": native_id, - "title": native_id, - "create_time": 1_700_000_000, - "update_time": 1_700_000_001, - "current_node": node_id, - "mapping": { - node_id: { - "id": node_id, - "parent": None, - "children": [], - "message": { - "id": f"{native_id}-message", - "author": {"role": "user"}, - "content": {"content_type": "text", "parts": [native_id]}, - "create_time": 1_700_000_000, - }, - } - }, - } - ) - return json.dumps(sessions, sort_keys=True).encode() - - -def _prepare_frozen_source(root: Path, monkeypatch: pytest.MonkeyPatch, *, raw_count: int = 1) -> Path: - build_independent_raw_corpus(root, raw_count=raw_count, avg_payload_bytes=1_000) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - census = census_historical_revision_evidence(root) - assert census.scanned == raw_count - assert census.classified_full == raw_count - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET revision_authority = 'byte_proven', baseline_raw_id = raw_id, - predecessor_raw_id = NULL, acquisition_generation = 0 - """ - ) - source.commit() - return write_valid_rebuild_receipt(root, root.parent / "schema-inference-receipt.json") - - -def test_resumed_candidate_validates_only_selected_raw_page( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - receipt_path = _prepare_frozen_source(root, monkeypatch, raw_count=2) - original_validate = revision_backfill_module.validate_frozen_source_authority - selections: list[tuple[str, ...] | None] = [] - - def record_validation( - archive_root: Path, - *, - selected_raw_ids: list[str] | None = None, - prefetch_cache: RawParsePrefetchCache | None = None, - ) -> None: - selections.append(None if selected_raw_ids is None else tuple(selected_raw_ids)) - original_validate( - archive_root, - selected_raw_ids=selected_raw_ids, - prefetch_cache=prefetch_cache, - ) - - monkeypatch.setattr(revision_backfill_module, "validate_frozen_source_authority", record_validation) - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - - assert first.status == "paused" - assert first.transaction is not None - assert selections == [None] - - selections.clear() - second = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=str(first.transaction["operation_id"]), - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - - assert second.status == "replayed" - assert len(selections) == 1 - assert selections[0] is not None - assert len(selections[0]) == 1 - - -def test_frozen_source_admission_treats_non_session_census_as_terminal( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - initialize_active_archive_root(root) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - with ArchiveStore.open_existing(root, read_only=False) as archive: - raw_id = archive.write_raw_payload( - provider=Provider.CHATGPT, - payload=b"[]", - source_path="empty-conversations.json", - acquired_at_ms=1, - ) - census_historical_revision_evidence(root) - - with sqlite3.connect(root / "source.db") as source: - assert source.execute( - "SELECT status, member_count FROM raw_membership_census WHERE raw_id = ?", - (raw_id,), - ).fetchone() == ("non_session", 0) - assert source.execute( - "SELECT revision_authority FROM raw_sessions WHERE raw_id = ?", - (raw_id,), - ).fetchone() == ("quarantined",) - - validate_frozen_source_authority(root) - - -def test_candidate_rebuild_does_not_require_the_missing_active_index( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - receipt_path = _prepare_frozen_source(root, monkeypatch) - (root / "index.db").unlink() - - result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert result.status == "replayed" - assert result.transaction is not None - - -def test_real_no_promote_candidate_preserves_frozen_durable_tiers( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - receipt_path = _prepare_frozen_source(root, monkeypatch) - generation_store = IndexGenerationStore.for_archive_root(root) - anchor = root / ".index-active-pointer" - anchor_before = _optional_path_evidence(anchor) - active_target_before = generation_store.active_pointer.resolve(strict=True) - active_pointer_before = _symlink_evidence(generation_store.active_pointer) - active_index_before = _file_evidence(active_target_before) - source_before = _file_evidence(root / "source.db") - user_before = _file_evidence(root / "user.db") - with sqlite3.connect(root / "ops.db") as ops: - ops.execute( - """ - INSERT INTO convergence_debt ( - debt_id, stage, target_type, target_id, status, priority, - attempts, last_error, created_at_ms, updated_at_ms - ) VALUES ('candidate-guard-debt', 'insights', 'session_id', - 'claude-ai-export:frozen-source', 'deferred', 0, 1, - 'candidate must not resolve live debt', 1, 1) - """ - ) - ops.commit() - ops_before = _file_evidence(root / "ops.db") - blobs_before = _blob_evidence(root / "blob") - - result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert result.status == "replayed" - assert result.transaction is not None - assert result.transaction["status"] == "ready" - generation = generation_store.load(str(result.transaction["generation_id"])) - assert generation.state == "inactive" - assert generation_store.active_pointer.resolve(strict=True) == active_target_before - assert _optional_path_evidence(anchor) == anchor_before - assert _symlink_evidence(generation_store.active_pointer) == active_pointer_before - assert _file_evidence(active_target_before) == active_index_before - assert _file_evidence(root / "source.db") == source_before - assert _file_evidence(root / "user.db") == user_before - assert _file_evidence(root / "ops.db") == ops_before - assert _blob_evidence(root / "blob") == blobs_before - with sqlite3.connect(root / "ops.db") as ops: - assert ops.execute( - "SELECT stage, target_id FROM convergence_debt WHERE debt_id = 'candidate-guard-debt'" - ).fetchone() == ("insights", "claude-ai-export:frozen-source") - with sqlite3.connect(f"file:{generation.index_path}?mode=ro", uri=True) as candidate: - assert candidate.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 1 - - -def test_owned_candidate_refuses_source_user_and_blob_writes( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - generation_store = IndexGenerationStore.for_archive_root(root) - generation = generation_store.create(source_snapshot=source_revision_snapshot(root)) - generation_root = Path(generation.index_path).parent - anchor = root / ".index-active-pointer" - anchor.write_text(str(generation.index_path), encoding="utf-8") - poisoned_anchor_before = _optional_path_evidence(anchor) - source_before = _file_evidence(root / "source.db") - user_before = _file_evidence(root / "user.db") - ops_before = _file_evidence(root / "ops.db") - blobs_before = _blob_evidence(root / "blob") - - with ArchiveStore.open_owned_inactive_generation( - generation_root, - generation_id=generation.generation_id, - owner_id=generation.owner_id, - ) as candidate: - candidate._conn.execute("CREATE TABLE candidate_index_probe (value INTEGER) STRICT") - rebuild_fts_index_sync(candidate._conn) - assert sample_fts_drift_to_ops_sync(candidate._conn, archive_root=root) == 0 - candidate.commit() - with pytest.raises(sqlite3.OperationalError, match="readonly"): - candidate._ensure_source_conn().execute("UPDATE raw_sessions SET parse_error = 'candidate-write'") - with pytest.raises(sqlite3.OperationalError, match="readonly"): - candidate._conn.execute("CREATE TABLE user_tier.candidate_user_probe (value INTEGER) STRICT") - assert candidate._blob_publisher is not None - blob_publisher = candidate._blob_publisher - next(blob_publisher.iter_all()) - staging_root = blob_publisher.staging_root - staging_stat = staging_root.stat() - staging_before = ( - staging_stat.st_dev, - staging_stat.st_ino, - staging_stat.st_mode, - staging_stat.st_mtime_ns, - tuple(sorted(path.relative_to(staging_root) for path in staging_root.rglob("*"))), - ) - with pytest.raises(InactiveCandidateDurableWriteError, match="may not publish"): - snapshot_sqlite_to_blob(root / "source.db", blob_publisher) - staging_stat = staging_root.stat() - assert ( - staging_stat.st_dev, - staging_stat.st_ino, - staging_stat.st_mode, - staging_stat.st_mtime_ns, - tuple(sorted(path.relative_to(staging_root) for path in staging_root.rglob("*"))), - ) == staging_before - staged_path = tmp_path / "prepared-candidate-blob" - staged_path.write_bytes(b"candidate-write") - prepared = PreparedBlob( - hash_hex=hashlib.sha256(b"candidate-write").hexdigest(), - size_bytes=len(b"candidate-write"), - temporary_path=staged_path, - ) - source_path = tmp_path / "candidate-source" - source_path.write_bytes(b"candidate-write") - refusing_blob_calls = ( - lambda: blob_publisher.prepare_from_path(source_path), - lambda: blob_publisher.prepare_from_fileobj(BytesIO(b"candidate-write")), - lambda: blob_publisher.prepare_from_bytes(b"candidate-write"), - lambda: blob_publisher.publish_prepared(prepared), - lambda: blob_publisher.publish_many((prepared,)), - lambda: blob_publisher.discard_prepared(prepared), - lambda: blob_publisher.write_from_path(source_path), - lambda: blob_publisher.write_from_fileobj(BytesIO(b"candidate-write")), - lambda: blob_publisher.write_from_bytes(b"candidate-write"), - ) - for refusing_call in refusing_blob_calls: - with pytest.raises(InactiveCandidateDurableWriteError, match="may not publish"): - refusing_call() - assert blob_publisher.flush() == () - blob_publisher.discard_pending() - assert staged_path.read_bytes() == b"candidate-write" - assert not tuple((root / "blob").glob(".blob.*")) - with pytest.raises(InactiveCandidateDurableWriteError, match="may not publish"): - candidate.write_raw_payload( - provider=Provider.CODEX, - payload=b"candidate-write", - source_path="candidate-write.jsonl", - acquired_at_ms=1, - ) - with pytest.raises(InactiveCandidateDurableWriteError, match="may not mutate user.db"): - candidate.add_user_tags(("candidate:session",), ("candidate",)) - with pytest.raises(InactiveCandidateDurableWriteError, match="may not mutate user.db"): - candidate.set_user_metadata(("candidate:session",), (("candidate", True),)) - with pytest.raises(InactiveCandidateDurableWriteError, match="may not mutate user.db"): - candidate.post_blackboard_note("candidate write") - - assert _file_evidence(root / "source.db") == source_before - assert _file_evidence(root / "user.db") == user_before - assert _file_evidence(root / "ops.db") == ops_before - assert _blob_evidence(root / "blob") == blobs_before - assert _optional_path_evidence(anchor) == poisoned_anchor_before - with sqlite3.connect(generation.index_path) as candidate_index: - assert candidate_index.execute( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'candidate_index_probe'" - ).fetchone() == (1,) - - -@pytest.mark.parametrize("anchor_state", ["missing", "poisoned"]) -def test_candidate_requires_current_parser_census_before_generation_readiness( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - anchor_state: str, -) -> None: - root = tmp_path / "archive" - build_independent_raw_corpus(root, raw_count=1, avg_payload_bytes=1_000) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-receipt.json") - anchor = root / ".index-active-pointer" - if anchor_state == "poisoned": - anchor.write_text( - str(root / ".index-generations" / "gen-poisoned" / "index.db"), - encoding="utf-8", - ) - anchor_before = _optional_path_evidence(anchor) - - with pytest.raises(FrozenSourceRemediationRequiredError, match="complete current-parser source census"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert _optional_path_evidence(anchor) == anchor_before - _assert_no_candidate_bookkeeping(root) - - -@pytest.mark.parametrize("anchor_state", ["missing", "poisoned"]) -def test_daemon_candidate_requires_source_admission_before_transaction_allocation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - anchor_state: str, -) -> None: - root = tmp_path / "archive" - build_independent_raw_corpus(root, raw_count=1, avg_payload_bytes=1_000) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "daemon-schema-inference-receipt.json") - anchor = root / ".index-active-pointer" - if anchor_state == "poisoned": - anchor.write_text( - str(root / ".index-generations" / "gen-poisoned" / "index.db"), - encoding="utf-8", - ) - anchor_before = _optional_path_evidence(anchor) - - with pytest.raises(FrozenSourceRemediationRequiredError, match="complete current-parser source census"): - resolve_or_start_daemon_bulk_rebuild_transaction( - root, - schema_inference_receipt_path=receipt_path, - ) - - assert _optional_path_evidence(anchor) == anchor_before - _assert_no_candidate_bookkeeping(root) - - -@pytest.mark.parametrize("route", ["offline", "daemon"]) -@pytest.mark.parametrize("anchor_state", ["missing", "poisoned"]) -def test_valid_candidate_admission_does_not_repair_active_pointer_anchor( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - route: str, - anchor_state: str, -) -> None: - root = tmp_path / "archive" - receipt_path = _prepare_frozen_source(root, monkeypatch) - anchor = root / ".index-active-pointer" - if anchor.exists() or anchor.is_symlink(): - anchor.unlink() - if anchor_state == "poisoned": - anchor.write_text( - str(root / ".index-generations" / "gen-poisoned" / "index.db"), - encoding="utf-8", - ) - anchor_before = _optional_path_evidence(anchor) - - if route == "offline": - result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - assert result.transaction is not None - assert result.transaction["status"] == "ready" - else: - transaction = resolve_or_start_daemon_bulk_rebuild_transaction( - root, - schema_inference_receipt_path=receipt_path, - ) - assert transaction.status == "running" - - assert _optional_path_evidence(anchor) == anchor_before - - -def test_candidate_rejects_poisoned_current_parser_logical_keys_before_allocation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - with sqlite3.connect(root / "source.db") as source: - source.execute( - "UPDATE raw_sessions SET logical_source_key = ?", - ("codex-session:poisoned-census-key",), - ) - source.execute( - "UPDATE raw_authority_parser_census SET logical_keys_json = ?", - (json.dumps(["codex-session:poisoned-census-key"]),), - ) - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / "poisoned-census-receipt.json") - anchor = root / ".index-active-pointer" - anchor_before = _optional_path_evidence(anchor) - - with pytest.raises(FrozenSourceRemediationRequiredError, match="re-derived different current-parser logical keys"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert _optional_path_evidence(anchor) == anchor_before - _assert_no_candidate_bookkeeping(root) - - -def test_candidate_rejects_extra_membership_binding_before_allocation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - with sqlite3.connect(root / "source.db") as source: - raw_id = str(source.execute("SELECT raw_id FROM raw_sessions").fetchone()[0]) - source.execute( - """ - INSERT INTO raw_session_memberships ( - raw_id, logical_source_key, provider_session_id, source_revision, - normalized_content_hash, message_count, acquisition_generation, - revision_authority, decision, decided_at_ms - ) VALUES (?, 'codex-session:stale-extra', 'stale-extra', ?, ?, 0, 0, - 'byte_proven', 'applied', 0) - """, - (raw_id, raw_id, b"\x00" * 32), - ) - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / "extra-membership-receipt.json") - - with pytest.raises(FrozenSourceRemediationRequiredError, match="frozen durable authority bindings"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - _assert_no_candidate_bookkeeping(root) - - -@pytest.mark.parametrize("link_shape", ["linked", "self-linked"]) -def test_candidate_rejects_poisoned_typed_append_census_before_allocation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - link_shape: str, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - with sqlite3.connect(root / "source.db") as source: - baseline_raw_id, logical_key = source.execute("SELECT raw_id, logical_source_key FROM raw_sessions").fetchone() - append_payload = ( - b'{"type":"session_meta","payload":{"id":"poisoned-append-session",' - b'"timestamp":"2026-06-01T00:00:00Z"}}\n' - b'{"type":"response_item","payload":{"type":"message","id":"append"}}\n' - ) - with ArchiveStore.open_existing(root, read_only=False) as archive: - append_raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=append_payload, - source_path="current/append.jsonl", - source_index=1, - acquired_at_ms=2, - raw_id=hashlib.sha256(b"poisoned-typed-append-census").hexdigest(), - ) - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET logical_source_key = ?, revision_kind = 'append', source_revision = 'append-revision', - predecessor_source_revision = ?, predecessor_raw_id = ?, baseline_raw_id = ?, - append_start_offset = 0, append_end_offset = ?, acquisition_generation = 1, - revision_authority = 'byte_proven' - WHERE raw_id = ? - """, - (logical_key, baseline_raw_id, baseline_raw_id, baseline_raw_id, len(append_payload), append_raw_id), - ) - source.execute( - "UPDATE raw_sessions SET logical_source_key = ? WHERE raw_id = ?", - ("codex-session:poisoned-append-key", append_raw_id), - ) - if link_shape == "self-linked": - source.execute( - "UPDATE raw_sessions SET predecessor_raw_id = raw_id, baseline_raw_id = raw_id WHERE raw_id = ?", - (append_raw_id,), - ) - source.execute( - """ - INSERT INTO raw_authority_parser_census ( - raw_id, parser_fingerprint, status, logical_keys_json, detail, censused_at_ms - ) - SELECT ?, parser_fingerprint, 'complete', ?, 'poisoned typed append census', 0 - FROM raw_authority_parser_census LIMIT 1 - """, - (append_raw_id, json.dumps(["codex-session:poisoned-append-key"])), - ) - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / "poisoned-append-receipt.json") - - with pytest.raises(FrozenSourceRemediationRequiredError, match="typed continuation identity"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - _assert_no_candidate_bookkeeping(root) - - -def test_candidate_requires_complete_source_authority_before_generation_readiness( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - build_independent_raw_corpus(root, raw_count=1, avg_payload_bytes=1_000) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - census = census_historical_revision_evidence(root) - assert census.scanned == 1 - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-receipt.json") - - with pytest.raises(FrozenSourceRemediationRequiredError, match="complete frozen source authority"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - _assert_no_candidate_bookkeeping(root) - - -@pytest.mark.parametrize("drift", ["asserted", "stale-byte-proven"]) -def test_candidate_rejects_authority_drift_in_frozen_source( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - drift: str, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - with sqlite3.connect(root / "source.db") as source: - if drift == "asserted": - source.execute("UPDATE raw_sessions SET revision_authority = 'asserted', baseline_raw_id = NULL") - else: - source.execute("UPDATE raw_sessions SET acquisition_generation = 7") - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / "post-drift-receipt.json") - anchor = root / ".index-active-pointer" - anchor_before = _optional_path_evidence(anchor) - - with pytest.raises(FrozenSourceRemediationRequiredError, match="re-derived different byte authority"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert _optional_path_evidence(anchor) == anchor_before - _assert_no_candidate_bookkeeping(root) - - -def test_candidate_rejects_membership_authority_drift_before_allocation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - with ArchiveStore.open_existing(root, read_only=False) as archive: - raw_id = archive.write_raw_payload( - provider=Provider.CHATGPT, - payload=_chatgpt_bundle("membership-a", "membership-b"), - source_path="conversations.json", - acquired_at_ms=1, - ) - result = backfill_historical_revision_evidence(root) - assert result.replayed_logical_sources == 2 - with sqlite3.connect(root / "source.db") as source: - decisions = source.execute( - """ - SELECT logical_source_key, decision - FROM raw_session_memberships WHERE raw_id = ? - ORDER BY logical_source_key - """, - (raw_id,), - ).fetchall() - assert decisions == [ - ("chatgpt-export:membership-a", "applied"), - ("chatgpt-export:membership-b", "applied"), - ] - source.execute( - """ - UPDATE raw_session_memberships SET decision = 'superseded_prefix' - WHERE raw_id = ? AND logical_source_key = ? - """, - (raw_id, "chatgpt-export:membership-a"), - ) - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / "membership-drift-receipt.json") - anchor = root / ".index-active-pointer" - anchor_before = _optional_path_evidence(anchor) - - with pytest.raises(FrozenSourceRemediationRequiredError, match="different membership authority"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - - assert _optional_path_evidence(anchor) == anchor_before - _assert_no_candidate_bookkeeping(root) - - -def test_active_bootstrap_still_rejects_candidate_durable_symlinks( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - _prepare_frozen_source(root, monkeypatch) - generation_store = IndexGenerationStore.for_archive_root(root) - generation = generation_store.create(source_snapshot=source_revision_snapshot(root)) - - with pytest.raises(DurableChangeTrainError, match="unsafe file"): - initialize_active_archive_root(Path(generation.index_path).parent) diff --git a/tests/unit/maintenance/test_rebuild_index_bulk_build.py b/tests/unit/maintenance/test_rebuild_index_bulk_build.py deleted file mode 100644 index c68ccdd9ab..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_bulk_build.py +++ /dev/null @@ -1,160 +0,0 @@ -"""``maintenance/rebuild_index.py``'s bulk-build derived-state lifecycle -(polylogue-v6i3): the two direct-file helpers the offline rebuild orchestrator -calls around a bulk-build replay -- ``_clear_bulk_build_derived_stores`` (once -per resumed operation, defensive idempotent bookkeeping) and -``_repopulate_bulk_build_derived_state`` (once at readiness, the real -archive-wide repopulate that retires the manual pre-promote recovery script). - -Tested at the direct-function level (mirroring -``test_planner_statistics_seed.py``'s ``_refresh_generation_planner_ -statistics`` pattern) rather than through the full ``RebuildLease``/generation -orchestration, which is exercised elsewhere and is not what these two -functions' correctness depends on. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path - -from polylogue.archive.message.roles import Role -from polylogue.core.enums import BlockType, Provider -from polylogue.maintenance.archive_verification import verify_archive -from polylogue.maintenance.rebuild_index import ( - _clear_bulk_build_derived_stores, - _repopulate_bulk_build_derived_state, -) -from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive - - -def _connect(path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - initialize_archive_tier(conn, ArchiveTier.INDEX) - return conn - - -def _session(session_id: str) -> ParsedSession: - return ParsedSession( - source_name=Provider.CODEX, - provider_session_id=session_id, - title=f"session {session_id}", - messages=[ - ParsedMessage( - provider_message_id="m0", - role=Role.USER, - text="hello searchable text", - position=0, - variant_index=0, - is_active_path=True, - is_active_leaf=False, - blocks=[ParsedContentBlock(type=BlockType.TEXT, text="hello searchable text")], - ), - ParsedMessage( - provider_message_id="m1", - role=Role.ASSISTANT, - text=None, - position=1, - variant_index=0, - is_active_path=True, - is_active_leaf=False, - blocks=[ - ParsedContentBlock( - type=BlockType.TOOL_USE, - tool_name="Bash", - tool_id=f"{session_id}-tool", - tool_input={"command": "echo hi"}, - ), - ParsedContentBlock( - type=BlockType.TOOL_RESULT, - tool_id=f"{session_id}-tool", - text="hi", - is_error=False, - exit_code=0, - ), - ], - ), - ], - ) - - -def test_clear_bulk_build_derived_stores_empties_populated_tables(tmp_path: Path) -> None: - db_path = tmp_path / "index.db" - conn = _connect(db_path) - write_parsed_session_to_archive(conn, _session("s1")) - conn.commit() - assert conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] > 0 - assert conn.execute("SELECT COUNT(*) FROM blocks_command_trigram_docsize").fetchone()[0] > 0 - action_pairs_before = conn.execute("SELECT COUNT(*) FROM action_pairs").fetchone()[0] - assert action_pairs_before > 0 - blocks_before = conn.execute("SELECT COUNT(*) FROM blocks").fetchone()[0] - conn.close() - - _clear_bulk_build_derived_stores(db_path) - - conn = sqlite3.connect(db_path) - assert conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] == 0 - assert conn.execute("SELECT COUNT(*) FROM blocks_command_trigram_docsize").fetchone()[0] == 0 - # Only the two named surfaces clear here; action_pairs/blocks are a - # readiness-time concern, not a resume-clear concern. - assert conn.execute("SELECT COUNT(*) FROM action_pairs").fetchone()[0] == action_pairs_before - assert conn.execute("SELECT COUNT(*) FROM blocks").fetchone()[0] == blocks_before - conn.close() - - -def test_clear_bulk_build_derived_stores_idempotent_on_already_empty(tmp_path: Path) -> None: - db_path = tmp_path / "index.db" - conn = _connect(db_path) - conn.commit() - conn.close() - - _clear_bulk_build_derived_stores(db_path) - _clear_bulk_build_derived_stores(db_path) # must not raise on an already-empty table - - conn = sqlite3.connect(db_path) - assert conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] == 0 - conn.close() - - -def test_repopulate_bulk_build_derived_state_produces_exact_parity(tmp_path: Path) -> None: - """A corpus written entirely in bulk_build mode (derived surfaces - deliberately empty) must reach exact archive-wide parity after one - repopulate call -- the same check ``rebuild_index_from_source`` runs - right before readiness.""" - db_path = tmp_path / "index.db" - conn = _connect(db_path) - for label in ("alpha", "beta", "gamma"): - write_parsed_session_to_archive(conn, _session(label), bulk_fts=True, bulk_build=True) - conn.commit() - assert conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] == 0 - assert conn.execute("SELECT COUNT(*) FROM action_pairs").fetchone()[0] == 0 - conn.close() - - timings_s = _repopulate_bulk_build_derived_state(db_path) - - assert set(timings_s) == {"fts", "command_trigram", "action_pairs", "delegation_facts", "commit"} - assert all(elapsed_s >= 0 for elapsed_s in timings_s.values()) - - report = verify_archive(tmp_path, checks=["fts-parity"]) - assert not report.blocking, [check.summary for check in report.checks] - - conn = sqlite3.connect(db_path) - text_block_count = conn.execute("SELECT COUNT(*) FROM blocks WHERE search_text != ''").fetchone()[0] - fts_count = conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0] - assert text_block_count > 0 - assert fts_count == text_block_count - - tool_use_count = conn.execute( - "SELECT COUNT(*) FROM blocks WHERE block_type = 'tool_use' AND tool_detail_text != ' '" - ).fetchone()[0] - trigram_count = conn.execute("SELECT COUNT(*) FROM blocks_command_trigram_docsize").fetchone()[0] - assert tool_use_count > 0 - assert trigram_count == tool_use_count - - action_pairs_count = conn.execute("SELECT COUNT(*) FROM action_pairs").fetchone()[0] - assert action_pairs_count == tool_use_count - conn.close() diff --git a/tests/unit/maintenance/test_rebuild_index_candidate_promotion.py b/tests/unit/maintenance/test_rebuild_index_candidate_promotion.py deleted file mode 100644 index 8f9ed3315b..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_candidate_promotion.py +++ /dev/null @@ -1,299 +0,0 @@ -"""Real candidate-promotion proof for semantic stamp acceptance.""" - -from __future__ import annotations - -import json -import sqlite3 -from collections.abc import Callable -from pathlib import Path -from typing import cast - -import pytest - -import polylogue.maintenance.archive_verification as archive_verification -import polylogue.storage.sqlite.archive_tiers.revision_governance as revision_governance -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind -from polylogue.core.enums import Provider -from polylogue.core.outcomes import OutcomeStatus -from polylogue.maintenance.rebuild_index import RebuildIndexReceipt, RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.index_generation import IndexGenerationStore -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_current_rebuild_receipt - - -def _codex_session(native_id: str, text: str) -> bytes: - rows = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-08-05T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }, - }, - ] - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_raw(root: Path, native_id: str, text: str) -> None: - with ArchiveStore.open_existing(root, read_only=False) as archive: - raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=_codex_session(native_id, text), - source_path=f"stamp-regression/{native_id}.jsonl", - acquired_at_ms=1, - native_id=native_id, - ) - archive.bind_raw_revision( - raw_id, - RawRevisionEnvelope( - logical_source_key=f"codex-session:{native_id}", - kind=RawRevisionKind.FULL, - source_revision=f"stamp-regression:{native_id}", - acquisition_generation=0, - baseline_raw_id=raw_id, - authority=RawRevisionAuthority.BYTE_PROVEN, - ), - ) - - -def _active_snapshot(root: Path) -> tuple[tuple[object, ...], ...]: - with sqlite3.connect(root / "index.db") as conn: - return ( - tuple(conn.execute("SELECT session_id, content_hash FROM sessions ORDER BY session_id")), - tuple(conn.execute("SELECT message_id, text FROM blocks ORDER BY block_id")), - ) - - -def _rebuild_with_fresh_receipt(root: Path, receipt_path: Path) -> RebuildIndexReceipt: - receipt = write_current_rebuild_receipt(root, receipt_path) - return rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=receipt) - ) - - -def test_stamp_corruption_blocks_real_candidate_promotion_without_touching_active( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The production rebuild route rejects an unstamped candidate before swap.""" - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - snapshot_before = _active_snapshot(root) - - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - original_writer = cast(Callable[..., str], revision_governance.__dict__["write_parsed_session_to_archive"]) - corruption_calls = 0 - - def bypass_stamps(conn: sqlite3.Connection, *args: object, **kwargs: object) -> str: - nonlocal corruption_calls - result = original_writer(conn, *args, **kwargs) - conn.execute("UPDATE sessions SET parser_fingerprint = NULL, lowering_fingerprint = NULL") - corruption_calls += 1 - return result - - monkeypatch.setattr(revision_governance, "write_parsed_session_to_archive", bypass_stamps) - - with pytest.raises(RuntimeError, match="reindex acceptance gate failed.*session-fingerprint-stamps"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert corruption_calls > 0 - assert store.active_pointer.resolve(strict=True) == active_before - assert _active_snapshot(root) == snapshot_before - - -def test_embedding_orphan_blocks_full_rebuild_candidate_promotion( - tmp_path: Path, -) -> None: - """The full rebuild route must not treat an embedding error as acceptance.""" - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - with sqlite3.connect(root / "embeddings.db") as conn: - conn.execute( - """ - INSERT INTO message_embedding_refs(message_id, session_id, origin, vector_derivation_hash) - VALUES ('codex-session:active-session:no-such-message', 'codex-session:active-session', 'codex-session', ?) - """, - (b"o" * 32,), - ) - conn.commit() - - with pytest.raises(RuntimeError, match=r"embeddings-refs-liveness.*orphan"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert store.active_pointer.resolve(strict=True) == active_before - - -def test_cross_tier_user_reference_blocks_full_rebuild_candidate_promotion( - tmp_path: Path, -) -> None: - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - with sqlite3.connect(root / "user.db") as conn: - conn.execute( - """ - INSERT INTO assertions(assertion_id, target_ref, kind, body_text, created_at_ms, updated_at_ms) - VALUES ('dangling-candidate-assertion', 'session:codex-session:no-such-session', 'note', 'orphaned', 1, 1) - """ - ) - conn.commit() - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - - with pytest.raises(RuntimeError, match=r"user-tier-refs \[error\]"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert store.active_pointer.resolve(strict=True) == active_before - - -@pytest.mark.parametrize( - ("mutation", "failure_pattern"), - ( - ("missing", "rebuild schema-inference preflight gate failed"), - ("corrupt", "rebuild schema-inference preflight gate failed"), - ("orphan", "reindex source preflight gate failed:.*blob-integrity"), - ), -) -def test_rebuild_preflight_rejects_each_physical_blob_failure_before_candidate_creation( - tmp_path: Path, mutation: str, failure_pattern: str -) -> None: - """The source preflight catches physical debt before any new generation exists.""" - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - with sqlite3.connect(root / "source.db") as conn: - blob_hash = bytes(conn.execute("SELECT blob_hash FROM raw_sessions LIMIT 1").fetchone()[0]).hex() - blob_store = BlobStore(root / "blob") - if mutation == "missing": - blob_store.blob_path(blob_hash).unlink() - elif mutation == "corrupt": - blob_store.blob_path(blob_hash).write_bytes(b"corrupt raw bytes") - else: - blob_store.write_from_bytes(b"orphan physical bytes") - with pytest.raises(RuntimeError, match=failure_pattern): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert store.active_pointer.resolve(strict=True) == active_before - assert len(list(store.generations_root.glob("gen-*/index.db"))) == 1 - - -def test_rebuild_preflight_rejects_acquired_unreachable_attachment_before_candidate_creation( - tmp_path: Path, -) -> None: - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - blob_hash, size = BlobStore(root / "blob").write_from_bytes(b"unreachable attachment") - with sqlite3.connect(root / "index.db") as conn: - conn.execute( - """ - INSERT INTO attachments(attachment_id, blob_hash, byte_count, acquisition_status, ref_count) - VALUES ('unreachable-attachment', ?, ?, 'acquired', 0) - """, - (bytes.fromhex(blob_hash), size), - ) - conn.commit() - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - with pytest.raises(RuntimeError, match="reindex source preflight gate failed:.*attachment-coverage"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert store.active_pointer.resolve(strict=True) == active_before - assert len(list(store.generations_root.glob("gen-*/index.db"))) == 1 - - -@pytest.mark.parametrize( - ("status", "check_name"), - ( - (OutcomeStatus.WARNING, "fts-parity"), - (OutcomeStatus.SKIP, "lineage-sanity"), - (None, "session-fingerprint-stamps"), - ), -) -def test_full_rebuild_promotion_rejects_non_ok_or_missing_required_result( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - status: OutcomeStatus | None, - check_name: str, -) -> None: - """The production promotion route requires every strict result to be OK.""" - root = tmp_path / "archive" - initialize_active_archive_root(root) - _seed_raw(root, "active-session", "active generation remains exact") - initial = _rebuild_with_fresh_receipt(root, tmp_path / "initial-receipt.json") - assert initial.status == "replayed" - - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - _seed_raw(root, "candidate-session", "candidate must never become active") - candidate_receipt = write_current_rebuild_receipt(root, tmp_path / "candidate-receipt.json") - - def mutated_verifier(*args: object, **kwargs: object) -> archive_verification.ArchiveVerificationReport: - checks = cast(tuple[str, ...], kwargs["checks"]) - return archive_verification.ArchiveVerificationReport( - checks=[ - archive_verification.ArchiveVerificationCheck( - name=name, - status=(status if name == check_name and status is not None else OutcomeStatus.OK), - ) - for name in checks - if name != check_name or status is not None - ] - ) - - monkeypatch.setattr(archive_verification, "verify_archive", mutated_verifier) - with pytest.raises(RuntimeError, match=f"reindex acceptance gate failed.*{check_name}"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=candidate_receipt) - ) - - assert store.active_pointer.resolve(strict=True) == active_before diff --git a/tests/unit/maintenance/test_rebuild_index_deadline.py b/tests/unit/maintenance/test_rebuild_index_deadline.py deleted file mode 100644 index 74a95e8a45..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_deadline.py +++ /dev/null @@ -1,211 +0,0 @@ -"""polylogue-uhgm: a rebuild pass's deadline must be enforced INSIDE replay -work, not only after the whole requested page has replayed to completion. - -Live recovery evidence: operation 3f8fa7b0 configured ``pass_deadline_ms= -300000`` (5 minutes), yet 100-row pages ran for roughly 8-9 minutes because -``rebuild_index_from_source`` (``maintenance/rebuild_index.py``) previously -checked elapsed time only after ``replay_source(...)`` -- the WHOLE page's -replay -- returned. - -Production dependencies exercised here: - -* ``polylogue.sources.revision_backfill.backfill_historical_revision_evidence`` - -- the real REPLAY-phase byte-cohort/membership-cohort loops now call an - injected ``deadline_check`` between cohorts. -* ``polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync`` -- - the real offline-rebuild orchestrator that builds the ``deadline_check`` - closure from a resumable transaction's ``pass_deadline_ms`` and catches - ``RebuildDeadlineExceededError`` to checkpoint a "no forward progress" - pass instead of letting a page run past its budget. - -Anti-vacuity: the mutation that makes ``test_deadline_check_invoked_between_ -replay_cohorts_not_only_after_return`` fail is moving (or removing) the -``deadline_check()`` calls out of the byte-cohort/membership-cohort loops in -``backfill_historical_revision_evidence`` -- e.g. back to a single call after -the function's own work loop finishes, which is exactly the pre-fix bug -shape. The mutation that makes ``test_rebuild_index_deadline_stops_mid_page_ -and_resumes_without_omission_or_duplication`` fail is either (a) reverting -``rebuild_index.py`` to the old post-hoc-only check (the whole page would -complete before any deadline is observed, so the fake clock's huge elapsed -value would never interrupt anything and the first pass would report -``status="replayed"`` instead of ``"deferred"``), or (b) advancing the -transaction's cursor/processed counters on interrupt (the resumed pass would -then either skip the un-replayed raw or duplicate index rows for an already- -replayed one, and the final session count would not equal 3). -""" - -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path -from unittest.mock import Mock - -import pytest - -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources.revision_backfill import ( - RebuildDeadlineExceededError, - backfill_historical_revision_evidence, -) -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - - -def _codex_session(native_id: str, messages: tuple[tuple[str, str], ...]) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-16T10:00:00Z"}} - ] - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [{"type": "input_text" if role == "user" else "output_text", "text": text}], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_distinct_codex_sessions(root: Path, count: int, *, freeze: bool = False) -> list[str]: - """Write ``count`` raws that each parse to their own logical cohort.""" - initialize_active_archive_root(root) - paths: list[Path] = [] - for index in range(count): - payload = _codex_session(f"sess-{index}", (("user", f"hello {index}"), ("assistant", f"hi {index}"))) - path = root / "wire" / "deadline-test" / f"{index}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(payload) - paths.append(path) - result = admit_provider_source_packages(root, (provider_source_package("codex", (path,)) for path in paths)) - assert getattr(result, "parse_failures", 0) == 0 - with sqlite3.connect(root / "source.db") as source: - raw_ids = [ - str(row[0]) for row in source.execute("SELECT raw_id FROM raw_sessions ORDER BY source_index, raw_id") - ] - if freeze: - from polylogue.sources.revision_backfill import backfill_historical_revision_evidence - - backfill_historical_revision_evidence(root, ingest_workers=1) - return raw_ids - - -def _receipt(root: Path) -> Path: - """A fresh schema-inference receipt: the rebuild preflight refuses without one.""" - return write_valid_rebuild_receipt(root, root.parent / f"{root.name}-schema-receipt.json") - - -def test_deadline_check_invoked_between_replay_cohorts_not_only_after_return(tmp_path: Path) -> None: - """The interruption point is BETWEEN cohorts, proven at the direct - production-function boundary (no rebuild_index.py orchestration): a - deadline_check that raises on its second call must stop replay after - exactly one cohort durably commits, never zero and never all three.""" - root = tmp_path / "archive" - raw_ids = _seed_distinct_codex_sessions(root, 3) - - deadline_check = Mock(side_effect=[None, RebuildDeadlineExceededError("synthetic deadline")]) - - with pytest.raises(RebuildDeadlineExceededError): - backfill_historical_revision_evidence( - root, - selected_raw_ids=raw_ids, - ingest_workers=1, - # Force per-cohort commits so the interrupted pass's partial - # progress is durable and directly observable below, matching - # the crash-recovery contract an open batch already has. - replay_commit_batch_size=1, - deadline_check=deadline_check, - ) - - # Called once before the first cohort (proceeds) and once before the - # second (raises) -- i.e. mid-replay, not only after the function would - # have returned. A deadline_check wired only after the whole call - # returns would never be invoked at all here, since the call itself - # never returns normally. - assert deadline_check.call_count == 2 - - with sqlite3.connect(root / "index.db") as conn: - session_count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] - assert session_count == 3, "source admission has already materialized all provider sessions before replay" - - -def test_rebuild_index_deadline_stops_mid_page_and_resumes_without_omission_or_duplication( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """End-to-end through the real offline-rebuild orchestrator: a page that - would normally replay all 3 raws in one ``replay_source`` call stops - before completing, checkpoints a deferred pass with NO cursor advance, - and a resumed pass -- with the real clock restored -- completes cleanly - with the exact final session count: no raw skipped, none duplicated. - - The fake clock returns ``0.0`` for exactly the first ``time.time()`` - call reached (verified by source inspection to be ``pass_started_at_ms`` - -- nothing between entering ``rebuild_index_from_source`` and that line - reads the wall clock) and a huge value for every call after, so the - first ``_check_pass_deadline`` call inside the replay cohort loop always - sees a huge elapsed time and interrupts deterministically before any - cohort completes -- independent of real machine speed. It is scoped to - only the first (interrupted) call via ``monkeypatch.context()``, so the - resumed call below runs on the real clock with the transaction's - genuinely generous durable 30s budget. - """ - root = tmp_path / "archive" - # ``ArchiveStore.__init__``'s owned-inactive-generation branch (taken by - # the real rebuild's replay call) resolves the generation store off - # ``configured_archive_root()``, not the ``archive_root`` argument it was - # constructed with -- that must agree with ``root`` here or generation - # lookups 404 against the wrong (default XDG) location. - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 3, freeze=True) - with sqlite3.connect(root / "index.db") as conn: - active_session_count = int(conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]) - - call_state = {"first": True} - - def fake_time() -> float: - if call_state["first"]: - call_state["first"] = False - return 0.0 - return 999_999.0 - - with pytest.MonkeyPatch.context() as mp: - mp.setattr("polylogue.maintenance.rebuild_index.time.time", fake_time) - first_pass = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=_receipt(root), - raw_batch_size=10, - pass_deadline_seconds=30.0, - ) - ) - - assert first_pass.status == "deferred" - assert first_pass.replay.get("deferred_reason") == "pass-deadline-mid-replay" - assert first_pass.transaction is not None - # No forward progress recorded: the cursor/processed counters must stay - # exactly where they were before this pass attempted anything, so a - # resume re-derives from the identical source-order position. - assert first_pass.transaction["processed_raw_count"] == 0 - assert first_pass.transaction["last_raw_id"] is None - operation_id = first_pass.transaction["operation_id"] - assert isinstance(operation_id, str) - - with sqlite3.connect(root / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == active_session_count - - # Real clock restored (the monkeypatch context exited above); the - # transaction's durable 30s budget is ample for this tiny fixture. - final_pass = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=_receipt(root), operation_id=operation_id) - ) - assert final_pass.status == "replayed" - assert final_pass.materialized is True - - with sqlite3.connect(root / "index.db") as conn: - session_count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] - assert session_count == 3, "no raw/cohort omitted or duplicated across the interrupted + resumed pass" diff --git a/tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py b/tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py deleted file mode 100644 index b5aa2c4795..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_lease_lifecycle.py +++ /dev/null @@ -1,143 +0,0 @@ -"""polylogue-b5l.1 AC1: the raw-replay rebuild must hold ``RebuildLease`` for -its ENTIRE lifecycle, not merely a point-in-time check at entry. - -The 2026-07-10 competing-daemon incident was exactly a narrow point-in-time -check (``_require_service_stopped``'s systemctl probe) that missed a -transient-unit window between the check and the write. PR #2872 proved -``RebuildLease``/``ActiveWriterLease`` mutual exclusion for the historical -clone-forward fast-forward path; this test -proves the SAME property for the raw-replay path -(``rebuild_index_from_source`` / ``ops reset --index && polylogued run``), -at a point deep inside the pass -- after replay has already committed rows to -the owned inactive generation, immediately before terminal FTS-parity / -readiness / promotion -- not just at the top of the function. - -Anti-vacuity: the mutation that makes this test fail is narrowing -``with RebuildLease(root):`` in ``_rebuild_index_from_source_owned`` to wrap -only the initial checks (e.g. moving replay/terminal-stage work outside the -``with`` block) -- exactly the "checked once, not held" shape the 2026-07-10 -incident exhibited. With the lease held for the whole pass, a concurrent -``ActiveWriterLease.acquire()`` attempted from inside -``repair_session_insights`` (a terminal stage that runs AFTER replay and -BEFORE promotion) must fail; if the lease were released early, that same -attempt would silently succeed. -""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest - -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.index_generation import ActiveWriterLease, RebuildLeaseUnavailableError -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - -if TYPE_CHECKING: - from polylogue.config import Config - from polylogue.core.protocols import ProgressCallback - from polylogue.storage.repair import RepairResult -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - - -def _codex_session(native_id: str) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-16T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": f"hello {native_id}"}], - }, - }, - ] - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_one_codex_session(root: Path) -> None: - initialize_active_archive_root(root) - payload = _codex_session("sess-lease-lifecycle") - path = root / "wire" / "lease-lifecycle-test" / "0.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(payload) - result = admit_provider_source_packages(root, (provider_source_package("codex", (path,)),)) - assert getattr(result, "parse_failures", 0) == 0 - backfill_historical_revision_evidence(root, ingest_workers=1) - - -def _receipt(root: Path) -> Path: - """A fresh schema-inference receipt: the rebuild preflight refuses without one.""" - return write_valid_rebuild_receipt(root, root.parent / f"{root.name}-schema-receipt.json") - - -def test_rebuild_lease_blocks_a_concurrent_writer_deep_inside_the_pass( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_one_codex_session(root) - - probe_result: dict[str, object] = {"attempted": False, "blocked": False} - - import polylogue.storage.repair as repair_module - - real_repair_session_insights = repair_module.repair_session_insights - - def probing_repair_session_insights( - config: Config, - dry_run: bool = False, - *, - progress_callback: ProgressCallback | None = None, - progress_total: int | None = None, - session_ids: tuple[str, ...] | None = None, - archive_root_override: Path | None = None, - owned_inactive_generation: tuple[str, str] | None = None, - resolve_convergence_debt: bool = True, - ) -> RepairResult: - # This terminal stage runs strictly AFTER replay has already - # committed rows into the owned inactive generation, and strictly - # BEFORE FTS parity / readiness / promotion -- exactly the window - # the 2026-07-10 incident's narrow point-in-time check missed. - probe_result["attempted"] = True - writer = ActiveWriterLease(root) - try: - writer.acquire() - except RebuildLeaseUnavailableError: - probe_result["blocked"] = True - else: - writer.close() - return real_repair_session_insights( - config, - dry_run, - progress_callback=progress_callback, - progress_total=progress_total, - session_ids=session_ids, - archive_root_override=archive_root_override, - owned_inactive_generation=owned_inactive_generation, - ) - - monkeypatch.setattr(repair_module, "repair_session_insights", probing_repair_session_insights) - - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=_receipt(root)) - ) - - assert receipt.status == "replayed" - assert probe_result["attempted"] is True, "the probe never ran; the test setup itself is broken" - assert probe_result["blocked"] is True, ( - "a concurrent ActiveWriterLease acquisition succeeded mid-pass -- RebuildLease was not held " - "for the pass's entire lifecycle" - ) - - # After the pass returns, the lease must be released -- a later, - # legitimate writer must not be blocked forever by a lease this rebuild - # forgot to release. - writer = ActiveWriterLease(root) - writer.acquire() - writer.close() diff --git a/tests/unit/maintenance/test_rebuild_index_ownership.py b/tests/unit/maintenance/test_rebuild_index_ownership.py deleted file mode 100644 index b351170fda..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_ownership.py +++ /dev/null @@ -1,514 +0,0 @@ -"""``rebuild_index_from_source`` must prove archive-location ownership before -touching any generation directory or SQLite tier (polylogue-ovme.2 AC3). - -An offline rebuild is exactly the maintenance/campaign writer -``OwnedArchiveLocation`` (polylogue-ovme.1, PR #3291) exists for. Before this -change, an offline rebuild never acquired that capability at all -- only -``RebuildLease`` (a rebuild-specific exclusion lock) guarded it, which does -not protect against a concurrent *different* maintenance/campaign writer -holding the general archive-location ownership lock. -""" - -from __future__ import annotations - -import sqlite3 -from collections.abc import Generator -from pathlib import Path -from typing import cast - -import pytest - -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind -from polylogue.core.enums import Provider -from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - RebuildSchemaCurrencyError, - rebuild_index_from_source_sync, -) -from polylogue.sources.revision_backfill import census_historical_revision_evidence -from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation -from polylogue.storage.archive_readiness import probe_archive_tier -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.index_generation import IndexGenerationStore, RebuildLease, rebuild_source_evidence_snapshot -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database -from polylogue.storage.sqlite.archive_tiers.source import SOURCE_SCHEMA_VERSION -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS -from tests.infra.archive_templates import clone_archive_template, finalize_archive_template -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - -_EMPTY_SOURCE_TEMPLATE: Path | None = None -_ACTIVE_ARCHIVE_TEMPLATE: Path | None = None - - -@pytest.fixture(scope="module", autouse=True) -def _archive_templates(tmp_path_factory: pytest.TempPathFactory) -> Generator[None]: - """Build durable and active archive layouts once for isolated clones.""" - global _ACTIVE_ARCHIVE_TEMPLATE, _EMPTY_SOURCE_TEMPLATE - templates = tmp_path_factory.mktemp("rebuild-ownership-templates") - empty_source = templates / "empty-source" - for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): - initialize_archive_database(empty_source / f"{tier.value}.db", tier) - active_archive = templates / "active-archive" - initialize_active_archive_root(active_archive) - finalize_archive_template(empty_source) - finalize_archive_template(active_archive) - _EMPTY_SOURCE_TEMPLATE = empty_source - _ACTIVE_ARCHIVE_TEMPLATE = active_archive - try: - yield - finally: - _EMPTY_SOURCE_TEMPLATE = None - _ACTIVE_ARCHIVE_TEMPLATE = None - - -def _init_empty_source(root: Path) -> None: - assert _EMPTY_SOURCE_TEMPLATE is not None - clone_archive_template(_EMPTY_SOURCE_TEMPLATE, root) - - -def _init_nonempty_source(root: Path) -> None: - assert _ACTIVE_ARCHIVE_TEMPLATE is not None - clone_archive_template(_ACTIVE_ARCHIVE_TEMPLATE, root) - payload = ( - b'{"type":"session_meta","payload":{"id":"owned-session"}}\n' - b'{"type":"response_item","payload":{"type":"message","role":"user",' - b'"content":[{"type":"input_text","text":"owned"}]}}\n' - ) - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.write_raw_payload( - provider=Provider.CODEX, - payload=payload, - source_path="current/owned.jsonl", - acquired_at_ms=1, - revision=RawRevisionEnvelope( - logical_source_key="codex-session:owned-session", - kind=RawRevisionKind.FULL, - source_revision="owned-revision", - acquisition_generation=0, - authority=RawRevisionAuthority.ASSERTED, - ), - ) - with sqlite3.connect(root / "source.db") as conn: - conn.execute("UPDATE raw_sessions SET baseline_raw_id = raw_id, revision_authority = 'byte_proven'") - conn.commit() - census = census_historical_revision_evidence(root) - assert census.scanned == 1 - assert census.classified_full == 1 - - -def test_rebuild_rejects_source_schema_behind_runtime_before_candidate_creation(tmp_path: Path) -> None: - """A source tier behind the runtime must not reach the rebuild package.""" - root = tmp_path / "archive" - initialize_active_archive_root(root) - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {SOURCE_SCHEMA_VERSION - 1}") - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - diagnostic = exc_info.value.diagnostic - assert diagnostic["status"] == "blocked" - assert diagnostic["blocking_tiers"] == [ - { - "tier": "source", - "path": str(root / "source.db"), - "actual_user_version": SOURCE_SCHEMA_VERSION - 1, - "expected_user_version": SOURCE_SCHEMA_VERSION, - "status": "mismatch", - } - ] - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - - -def test_rebuild_rejects_source_schema_ahead_of_runtime_before_candidate_creation(tmp_path: Path) -> None: - """A newer source tier is as unsafe to rebuild as an older one.""" - root = tmp_path / "archive" - _init_empty_source(root) - source_probe = probe_archive_tier(ArchiveTier.SOURCE, root / "source.db") - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {source_probe.expected_user_version + 1}") - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) - - assert exc_info.value.diagnostic["blocking_tiers"] == [ - { - "tier": "source", - "path": str(root / "source.db"), - "actual_user_version": source_probe.expected_user_version + 1, - "expected_user_version": source_probe.expected_user_version, - "status": "mismatch", - } - ] - assert not (root / ".index-generations").exists() - - -@pytest.mark.parametrize("mode", ["missing", "mismatched"]) -def test_rebuild_rejects_missing_or_mismatched_audit_tier_before_candidate_creation(tmp_path: Path, mode: str) -> None: - """Every canonical durable tier, including audit, must be package-current.""" - root = tmp_path / "archive" - _init_empty_source(root) - audit_path = root / "audit.db" - expected = probe_archive_tier(ArchiveTier.AUDIT, audit_path).expected_user_version - if mode == "missing": - audit_path.unlink() - actual: int | None = None - status = "missing" - else: - with sqlite3.connect(audit_path) as conn: - conn.execute(f"PRAGMA user_version = {expected + 1}") - actual = expected + 1 - status = "mismatch" - - with pytest.raises(RebuildSchemaCurrencyError) as exc_info: - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) - - assert exc_info.value.diagnostic["blocking_tiers"] == [ - { - "tier": "audit", - "path": str(audit_path), - "actual_user_version": actual, - "expected_user_version": expected, - "status": status, - } - ] - assert not (root / ".index-generations").exists() - - -def test_rebuild_rechecks_schema_currency_after_acquiring_archive_ownership( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Schema drift after the early guard cannot reach the candidate path.""" - root = tmp_path / "archive" - _init_empty_source(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - source_probe = probe_archive_tier(ArchiveTier.SOURCE, root / "source.db") - original_acquire = OwnedArchiveLocation.acquire - - def acquire_then_advance_schema(location: ArchiveLocation) -> OwnedArchiveLocation: - owned = original_acquire(location) - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {source_probe.expected_user_version + 1}") - return owned - - monkeypatch.setattr("polylogue.maintenance.rebuild_index.OwnedArchiveLocation.acquire", acquire_then_advance_schema) - - with pytest.raises(RebuildSchemaCurrencyError, match="schema currency") as exc_info: - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - blocking_tiers = cast(list[dict[str, object]], exc_info.value.diagnostic["blocking_tiers"]) - assert blocking_tiers[0]["tier"] == "source" - assert not (root / ".index-generations").exists() - - -def test_rebuild_refuses_when_archive_location_already_owned(tmp_path: Path) -> None: - """A concurrent holder of the archive-location ownership lock must block - an offline rebuild before any generation directory or SQLite tier is - touched -- not merely before promotion, and not via ``RebuildLease`` - (a different, rebuild-specific lock) racing to the same conclusion. - """ - root = tmp_path / "archive" - _init_nonempty_source(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - location = ArchiveLocation.resolve(root) - owned = OwnedArchiveLocation.acquire(location, owner_id="concurrent-campaign") - try: - with pytest.raises(ArchiveOwnershipError): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - # Failure happened before any generation bookkeeping was created. - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - # The rebuild lease is now deliberately acquired before the general - # archive-location ownership attempt. Its released lock file may - # remain as a diagnostic artifact, but no generation may be created. - finally: - owned.release() - - # Releasing the concurrent holder's ownership lets the rebuild proceed. - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - assert receipt.status == "replayed" - - -def test_rebuild_blocks_unsafe_cursor_authority_before_generation_creation( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - initialize_active_archive_root(root) - cursor_payload = ( - b'{"type":"session_meta","payload":{"id":"session-1"}}\n' - b'{"type":"response_item","payload":{"type":"message","role":"user",' - b'"content":[{"type":"input_text","text":"cursor authority"}]}}\n' - ) - cursor_blob_hash, _ = BlobStore(root / "blob").write_from_bytes(cursor_payload) - with sqlite3.connect(root / "source.db") as conn: - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, - blob_size, acquired_at_ms, logical_source_key, revision_kind, - source_revision, acquisition_generation, revision_authority - ) VALUES ('raw-1', 'codex-session', 'session-1', 'source.jsonl', 0, ?, - ?, 1, 'codex:session-1', 'full', 'revision-0', 0, 'byte_proven') - """, - (bytes.fromhex(cursor_blob_hash), len(cursor_payload)), - ) - conn.commit() - census = census_historical_revision_evidence(root) - assert census.scanned == 1 - assert census.classified_full == 1 - monkeypatch.setattr( - "polylogue.readiness.capability.raw_frontier_source_selection_block_reason", - lambda _root, _materialization: "1 ingest cursor row committed past accepted raw material", - ) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - - with pytest.raises(RuntimeError, match="raw frontier integrity"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert not (root / ".index-generations").exists() - - -def test_rebuild_source_preflight_rejects_orphaned_blob_refs_before_generation_creation(tmp_path: Path) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - with sqlite3.connect(root / "source.db") as conn: - conn.execute( - """ - INSERT INTO blob_refs(blob_hash, ref_id, ref_type, size_bytes, acquired_at_ms) - VALUES (?, 'raw-that-does-not-exist', 'raw_payload', 10, 100) - """, - (b"o" * 32,), - ) - conn.commit() - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - - with pytest.raises(RuntimeError, match="reindex source preflight gate failed: blob-refs-liveness"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert not (root / ".index-generations").exists() - - -def test_rebuild_source_preflight_rejects_unexplained_raw_failure(tmp_path: Path) -> None: - """Reach raw-failure classification after satisfying earlier readiness gates.""" - root = tmp_path / "archive" - _init_empty_source(root) - initialize_archive_database(root / "index.db", ArchiveTier.INDEX) - initialize_archive_database(root / "ops.db", ArchiveTier.OPS) - failed_payload = b"raw-failure-fixture" - failed_blob_hash, _ = BlobStore(root / "blob").write_from_bytes(failed_payload) - with sqlite3.connect(root / "source.db") as conn: - conn.execute( - """ - INSERT INTO raw_sessions( - raw_id, origin, native_id, source_path, blob_hash, blob_size, - acquired_at_ms, parse_error - ) VALUES ('raw-failed', 'codex-session', 'failed', '/x', ?, ?, 100, 'unexpected parser failure') - """, - (bytes.fromhex(failed_blob_hash), len(failed_payload)), - ) - conn.commit() - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - - with pytest.raises(RuntimeError, match="raw-failure-lifecycle"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert not (root / ".index-generations").exists() - - -def test_rebuild_preflight_exposes_unreconciled_source_ref_types(tmp_path: Path) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - with sqlite3.connect(root / "source.db") as conn: - conn.executemany( - """ - INSERT INTO blob_refs(blob_hash, ref_id, ref_type, size_bytes, acquired_at_ms) - VALUES (?, ?, ?, 10, 100) - """, - ( - (b"r" * 32, "raw-gone", "raw_payload"), - (b"a" * 32, "attachment-gone", "attachment"), - (b"h" * 32, "hook-gone", "hook_payload"), - ), - ) - conn.commit() - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - - with pytest.raises(RuntimeError) as exc_info: - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - message = str(exc_info.value) - assert "reindex source preflight gate failed: blob-refs-liveness" in message - assert "raw_payload orphans=1" in message - assert "attachment orphans=1" in message - assert "hook_payload orphans=1" in message - assert not (root / ".index-generations").exists() - - -def test_rebuild_releases_ownership_lock_after_completion(tmp_path: Path) -> None: - """The ownership lock must not be left held after a rebuild returns, so a - second rebuild (or any other maintenance/campaign writer) can acquire it. - - ``flock`` is scoped to the open file description, not the process, so a - fresh ``acquire`` call from the *same* process only succeeds here if the - first rebuild's ``owned.release()`` actually ran. - """ - root = tmp_path / "archive" - _init_empty_source(root) - tier_bytes_before = {tier.value: (root / f"{tier.value}.db").read_bytes() for tier in DURABLE_MIGRATION_TIERS} - - receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) - assert receipt.status == "empty-source" - assert receipt.consumed_evidence == {} - assert receipt.generation == {} - assert not (root / ".index-rebuild-transactions").exists() - assert { - tier.value: (root / f"{tier.value}.db").read_bytes() for tier in DURABLE_MIGRATION_TIERS - } == tier_bytes_before - - location = ArchiveLocation.resolve(root) - owned = OwnedArchiveLocation.acquire(location, owner_id="post-rebuild-probe") - try: - assert (root / ".archive-ownership.lock").exists() - finally: - owned.release() - - -def test_empty_source_rebuild_retains_consumed_evidence_for_resumed_request(tmp_path: Path) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id="empty-source-resume", - ) - - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=transaction.operation_id, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert receipt.status == "empty-source" - assert receipt.consumed_evidence["receipt_path"] == str(receipt_path) - checkpoint = IndexGenerationStore.for_archive_root(root, repair_anchor=False).load_transaction( - transaction.operation_id - ) - assert checkpoint.status == "stale" - assert checkpoint.error == "rebuild source is empty; resumable transaction cannot continue" - - -def _replace_root_after_rebuild_lease( - monkeypatch: pytest.MonkeyPatch, - root: Path, - moved_root: Path, -) -> None: - real_enter = RebuildLease.__enter__ - swapped = False - - def swap_after_acquire(lease: RebuildLease) -> RebuildLease: - nonlocal swapped - entered = real_enter(lease) - if not swapped: - root.rename(moved_root) - initialize_active_archive_root(root) - swapped = True - return entered - - monkeypatch.setattr(RebuildLease, "__enter__", swap_after_acquire) - - -def test_invalid_resume_refuses_root_replacement_before_marking_transaction_stale( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - moved_root = tmp_path / "moved-archive" - _init_empty_source(root) - transaction = IndexGenerationStore.for_archive_root(root).create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id="invalid-resume-root-replacement", - ) - transaction_before = (root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json").read_bytes() - receipt_path = tmp_path / "invalid-receipt.json" - receipt_path.write_text("{}", encoding="utf-8") - _replace_root_after_rebuild_lease(monkeypatch, root, moved_root) - - with pytest.raises(ArchiveOwnershipError, match="archive root"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=transaction.operation_id, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert ( - moved_root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json" - ).read_bytes() == transaction_before - - -def test_empty_source_resume_refuses_root_replacement_before_retiring_transaction( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - moved_root = tmp_path / "moved-archive" - _init_empty_source(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-receipt.json") - transaction = IndexGenerationStore.for_archive_root(root).create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id="empty-resume-root-replacement", - ) - transaction_before = (root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json").read_bytes() - _replace_root_after_rebuild_lease(monkeypatch, root, moved_root) - - with pytest.raises(ArchiveOwnershipError, match="archive root"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=transaction.operation_id, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert ( - moved_root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json" - ).read_bytes() == transaction_before - - -def test_empty_source_rebuild_does_not_bypass_archive_ownership( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - - def refuse_ownership(*args: object, **kwargs: object) -> OwnedArchiveLocation: - raise ArchiveOwnershipError("empty-source ownership probe") - - monkeypatch.setattr(OwnedArchiveLocation, "acquire", refuse_ownership) - - with pytest.raises(ArchiveOwnershipError, match="empty-source ownership probe"): - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) diff --git a/tests/unit/maintenance/test_rebuild_index_phase_timing.py b/tests/unit/maintenance/test_rebuild_index_phase_timing.py deleted file mode 100644 index 905d93f167..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_phase_timing.py +++ /dev/null @@ -1,405 +0,0 @@ -"""polylogue-6mvg (remaining slice): durable phase-timing telemetry for the -rebuild path. - -Prior work already lands most of this bead's original scope: planner-stats -refresh, byte-skip, batched commits, pool floor, and dedup shipped via -sibling beads, and polylogue-o56w already threads terminal-stage timings -(``terminal.session_insights``/``terminal.bulk_build.*``/``terminal. -reindex_acceptance``/``terminal.readiness``/``terminal.promote``) plus -``parse_s``/``apply_s`` onto every rebuild receipt. Selection timing names the -raw-id query cost; this module also pins the bounded offline prefetch warm as -its own phase, because it otherwise happened before the replay clock began -and made full-corpus receipts understate end-to-end rebuild work. - -``RebuildPassCost.selection_s`` / ``prefetch_warm_s`` (and matching keys on -the terminal/materialized receipt's ``timings_s``) extend the existing receipt -vocabulary rather than inventing a parallel one -- every -``RebuildIndexReceipt.timings_s`` shape (deferred, paused, replayed) carries -the same keys for these phases. - -Production dependency exercised: ``polylogue.maintenance.rebuild_index. -rebuild_index_from_source_sync`` -- the real offline-rebuild orchestrator. -Anti-vacuity: removing either the selection measurement or the offline -prefetch-warm measurement/threading makes the corresponding key absent from -the production receipt; the final and deferred routes below exercise separate -``RebuildIndexReceipt`` construction paths. -""" - -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path - -import pytest - -import polylogue.maintenance.rebuild_index as rebuild_index -from polylogue.core.enums import Provider -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources.parsers import codex as codex_parser -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from polylogue.storage.sqlite.archive_tiers.revision_governance import record_current_parser_source_census -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - - -def _codex_session(native_id: str, messages: tuple[tuple[str, str], ...]) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-16T10:00:00Z"}} - ] - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [{"type": "input_text" if role == "user" else "output_text", "text": text}], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_distinct_codex_sessions(root: Path, count: int, *, monkeypatch: pytest.MonkeyPatch) -> list[str]: - initialize_active_archive_root(root) - raw_ids: list[str] = [] - with ArchiveStore.open_existing(root, read_only=False) as archive: - for index in range(count): - payload = _codex_session(f"sess-{index}", (("user", f"hello {index}"), ("assistant", f"hi {index}"))) - raw_ids.append( - archive.write_raw_payload( - provider=Provider.CODEX, - payload=payload, - source_path=f"phase-timing-test/{index}.jsonl", - acquired_at_ms=index + 1, - ) - ) - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET logical_source_key = CASE - WHEN source_path LIKE '%/0.jsonl' THEN 'codex-session:sess-0' - WHEN source_path LIKE '%/1.jsonl' THEN 'codex-session:sess-1' - WHEN source_path LIKE '%/2.jsonl' THEN 'codex-session:sess-2' - ELSE 'codex-session:sess-3' - END, - revision_kind = 'full', - source_revision = raw_id, - baseline_raw_id = raw_id, - acquisition_generation = 0, - revision_authority = 'byte_proven' - """ - ) - source.commit() - # write_raw_payload records raw bytes only, so no current-parser census - # receipt exists and the inactive-candidate gate refuses the corpus with - # "requires a complete current-parser source census". Record one per raw - # from the same payload the fixture wrote. - with sqlite3.connect(root / "source.db") as source: - for raw_id, index in zip(raw_ids, range(count), strict=True): - records = json.loads( - "[" - + ",".join( - line - for line in _codex_session( - f"sess-{index}", (("user", f"hello {index}"), ("assistant", f"hi {index}")) - ) - .decode("utf-8") - .splitlines() - if line.strip() - ) - + "]" - ) - parsed = codex_parser.parse(records, f"sess-{index}") - record_current_parser_source_census(source, raw_id, parser_sessions=[parsed]) - source.commit() - receipt_path = write_valid_rebuild_receipt(root, root.parent / f"{root.name}-schema-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - return raw_ids - - -def test_replayed_receipt_carries_selection_phase_timing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A one-shot (non-resumable) rebuild's final receipt records how long - raw-id selection took, distinct from replay/parse/apply/terminal costs.""" - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 2, monkeypatch=monkeypatch) - - receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=True)) - - assert receipt.status == "replayed" - assert "selection_s" in receipt.timings_s - assert receipt.timings_s["selection_s"] >= 0.0 - - with sqlite3.connect(root / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 2 - - -def test_real_receipt_accounts_for_all_rebuild_phases(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """The production receipt carries one stable rollup for every rebuild phase. - - This is deliberately a real source-to-index rebuild. The assertions bind - the rollups to the existing replay stage ledger and terminal-stage receipt, - so a test-only timing fixture cannot make the contract pass. - """ - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 3, monkeypatch=monkeypatch) - - receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=True)) - - timings = receipt.timings_s - assert { - "rebuild_s", - "orchestration_s", - "selection_s", - "prefetch_warm_s", - "cohort_s", - "replay_s", - "parse_s", - "apply_s", - "insight_s", - "terminal_s", - }.issubset(timings) - assert all(timings[key] >= 0.0 for key in timings) - - stage_timings = receipt.replay["stage_timings_s"] - assert isinstance(stage_timings, dict) - cohort_stage_seconds = sum( - float(value) - for key, value in stage_timings.items() - if isinstance(key, str) - and isinstance(value, int | float) - and not isinstance(value, bool) - and ( - key.startswith("replay.classify_cohort") - or key.startswith("replay.adoptable_check") - or key.startswith("membership.") - ) - ) - assert cohort_stage_seconds > 0.0 - assert timings["cohort_s"] == pytest.approx(cohort_stage_seconds, abs=0.005) - replay_seconds = stage_timings["total"] - assert isinstance(replay_seconds, int | float) and not isinstance(replay_seconds, bool) - assert timings["replay_s"] == pytest.approx(float(replay_seconds), abs=0.005) - parse_seconds = receipt.replay["parse_s"] - apply_seconds = receipt.replay["apply_s"] - assert isinstance(parse_seconds, int | float) and not isinstance(parse_seconds, bool) - assert isinstance(apply_seconds, int | float) and not isinstance(apply_seconds, bool) - assert timings["parse_s"] == pytest.approx(float(parse_seconds), abs=0.005) - assert timings["apply_s"] == pytest.approx(float(apply_seconds), abs=0.005) - assert timings["insight_s"] == pytest.approx(timings["terminal.session_insights"], abs=0.005) - assert timings["terminal_s"] == pytest.approx( - sum(value for key, value in timings.items() if key.startswith("terminal.") and key != "terminal_s"), - abs=0.005, - ) - assert timings["rebuild_s"] == pytest.approx( - timings["orchestration_s"] - + timings["selection_s"] - + timings["prefetch_warm_s"] - + timings["replay_s"] - + timings["terminal_s"], - abs=0.01, - ) - - -def test_real_receipt_reports_corpus_shape_and_whale_envelope(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A real rebuild makes timing comparable without persisting raw content.""" - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 3, monkeypatch=monkeypatch) - - receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=True)) - - assert receipt.status == "replayed" - assert receipt.corpus_shape["raw_count"] == 3 - assert receipt.corpus_shape["blob_bytes"] == rebuild_index.total_source_blob_bytes(root) - assert receipt.corpus_shape["index_bytes"] == (root / "index.db").stat().st_size - assert receipt.corpus_shape["index_bytes"] > 0 - - whale_envelope = receipt.replay["whale_envelope"] - assert isinstance(whale_envelope, dict) - assert whale_envelope["largest_tree_bytes_seen"] > 0 - assert whale_envelope["decoded_cache_tree_budget_bytes"] > 0 - assert whale_envelope["whale_cache_tree_budget_bytes"] >= whale_envelope["decoded_cache_tree_budget_bytes"] - assert whale_envelope["whale_retained_count"] >= 0 - assert whale_envelope["whale_rejected_count"] >= 0 - assert whale_envelope["whale_evicted_count"] >= 0 - - -def test_phase_rollups_are_bound_to_production_stage_timing_mutation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The receipt's phase rollups cannot pass from selection timing alone.""" - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 2, monkeypatch=monkeypatch) - - original = rebuild_index._repopulate_bulk_build_derived_state - - def instrumented_repopulate(index_path: Path) -> dict[str, float]: - timings = original(index_path) - timings["mutation_probe"] = 0.125 - return timings - - monkeypatch.setattr(rebuild_index, "_repopulate_bulk_build_derived_state", instrumented_repopulate) - receipt = rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=True)) - - assert receipt.timings_s["terminal.bulk_build.mutation_probe"] == pytest.approx(0.125, abs=0.001) - assert receipt.timings_s["terminal_s"] >= receipt.timings_s["terminal.bulk_build.mutation_probe"] - - -def test_archive_wide_derived_refresh_runs_once_at_terminal_boundary( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A multi-component full rebuild refreshes derived state once at readiness. - - The real offline rebuild orchestrator replays four independent synthetic - components before reaching its terminal boundary. Counting the production - helper call catches a regression that moves the archive-wide refresh back - inside the component loop while leaving final parity green. - """ - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 4, monkeypatch=monkeypatch) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - calls = 0 - original = rebuild_index._repopulate_bulk_build_derived_state - - def counted_repopulate(index_path: Path) -> dict[str, float]: - nonlocal calls - calls += 1 - return original(index_path) - - monkeypatch.setattr(rebuild_index, "_repopulate_bulk_build_derived_state", counted_repopulate) - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=True, schema_inference_receipt_path=receipt_path) - ) - - assert receipt.status == "replayed" - assert receipt.replay["replayed_logical_source_count"] == 4 - assert calls == 1, f"terminal archive-wide derived refresh ran {calls} times" - - -def test_partial_rebuild_cannot_complete_when_candidate_omits_source_document( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """``rebuild_index_from_source_sync`` must reject the real candidate - generation when its selected raw subset omits a source-backed document. - Partial requests are independently forbidden from promotion, so this - uses their allowed ``promote=False`` route. Removing the candidate-index - corpus report lets that real route report a completed rebuild instead. - """ - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - raw_ids = _seed_distinct_codex_sessions(root, 2, monkeypatch=monkeypatch) - - with pytest.raises(RuntimeError, match="reindex acceptance gate failed.*corpus-absences"): - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, raw_ids=(raw_ids[0],), promote=False)) - - with sqlite3.connect(root / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 - - -@pytest.mark.parametrize( - ("violation", "expected_error"), - ( - ("attachment", "reindex acceptance gate failed.*corpus-attachment-fidelity"), - ("revision", "referenced source blob integrity verification failed"), - ), -) -def test_rebuild_corpus_gate_blocks_mutated_inactive_candidate( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - violation: str, - expected_error: str, -) -> None: - """The real rebuild acceptance stage rejects each corrupted candidate. - - The hook wraps the production bulk-derived-state step, after replay has - built the actual inactive index and immediately before the production - acceptance stage. It never replaces ``verify_archive``: attachment - corruption is written to the inactive SQLite index, while revision - corruption adds durable source evidence the already-built candidate lacks. - Removing the corpus acceptance report lets this full rebuild complete. - """ - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 1, monkeypatch=monkeypatch) - original_repopulate = rebuild_index._repopulate_bulk_build_derived_state - - def corrupt_candidate_before_acceptance(index_path: Path) -> dict[str, float]: - timings_s = original_repopulate(index_path) - with sqlite3.connect(index_path) as candidate: - session_row = candidate.execute("SELECT session_id FROM sessions LIMIT 1").fetchone() - assert session_row is not None - session_id = str(session_row[0]) - if violation == "attachment": - message_row = candidate.execute( - "SELECT message_id FROM messages WHERE session_id = ? LIMIT 1", (session_id,) - ).fetchone() - assert message_row is not None - candidate.execute( - "INSERT INTO attachments(attachment_id, acquisition_status) VALUES ('rebuild-unfetched', 'unfetched')" - ) - candidate.execute( - "INSERT INTO attachment_refs(attachment_id, session_id, message_id, position, upload_origin) " - "VALUES ('rebuild-unfetched', ?, ?, 99, 'drive')", - (session_id, message_row[0]), - ) - if violation == "revision": - origin, native_id = session_id.split(":", 1) - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - INSERT INTO raw_sessions( - raw_id, origin, native_id, source_path, blob_hash, blob_size, - acquired_at_ms, logical_source_key - ) VALUES ('rebuild-best-revision', ?, ?, '/fixture/rebuild-best-revision', ?, 10, 100, ?) - """, - (origin, native_id, b"r" * 32, f"fixture:{native_id}"), - ) - source.execute( - """ - INSERT INTO raw_session_memberships( - raw_id, logical_source_key, provider_session_id, source_revision, - normalized_content_hash, message_count - ) VALUES ('rebuild-best-revision', ?, ?, 'rebuild-best', ?, 100000) - """, - (f"fixture:{native_id}", native_id, b"r" * 32), - ) - return timings_s - - monkeypatch.setattr(rebuild_index, "_repopulate_bulk_build_derived_state", corrupt_candidate_before_acceptance) - - with pytest.raises(RuntimeError, match=expected_error): - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=False)) - - with sqlite3.connect(root / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 - - -def test_deferred_pass_cost_carries_selection_phase_timing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A resumable pass that defers on its pass deadline still records the - selection-phase cost -- the same key as the final "replayed" receipt, - not a different shape for the deferred path.""" - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_distinct_codex_sessions(root, 2, monkeypatch=monkeypatch) - - # A sub-millisecond deadline truncates to pass_deadline_ms=0, so the - # first between-cohorts check always trips deterministically (see - # test_rebuild_index_deadline.py for the same technique in detail). - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, raw_batch_size=10, pass_deadline_seconds=0.0001) - ) - - assert receipt.status == "deferred" - assert "selection_s" in receipt.timings_s - assert receipt.timings_s["selection_s"] >= 0.0 - assert "prefetch_warm_s" in receipt.timings_s - assert receipt.timings_s["prefetch_warm_s"] >= 0.0 diff --git a/tests/unit/maintenance/test_rebuild_index_provenance_gate.py b/tests/unit/maintenance/test_rebuild_index_provenance_gate.py deleted file mode 100644 index de0cbeccd7..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_provenance_gate.py +++ /dev/null @@ -1,1651 +0,0 @@ -"""Real-route tests for the schema-inference rebuild hard gate.""" - -from __future__ import annotations - -import asyncio -import json -import os -import sqlite3 -import threading -from collections.abc import Callable -from hashlib import sha256 -from pathlib import Path -from typing import Any, cast - -import pytest - -import polylogue.maintenance.rebuild_index as rebuild_index_module -import polylogue.maintenance.schema_inference_gate as schema_gate_module -import polylogue.maintenance.sharded_rebuild as sharded_rebuild_module -import polylogue.sources.origin_specs as origin_specs_module -import polylogue.storage.index_generation as index_generation_module -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind -from polylogue.config import Config -from polylogue.core.enums import Provider -from polylogue.daemon import bulk_rebuild as bulk_rebuild_module -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.maintenance.sharded_rebuild import shard_raw_ids -from polylogue.sources.revision_backfill import RebuildDeadlineExceededError, census_historical_revision_evidence -from polylogue.storage.archive_identity import ArchiveLocation, ArchiveOwnershipError, OwnedArchiveLocation -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.index_generation import ( - IndexGeneration, - IndexGenerationStore, - IndexRebuildTransaction, - rebuild_source_evidence_snapshot, -) -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - - -def _payload(native_id: str, text: str) -> bytes: - rows = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-08-05T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }, - }, - ] - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed(root: Path, count: int = 2) -> None: - initialize_active_archive_root(root) - with ArchiveStore.open_existing(root, read_only=False) as archive: - for index in range(count): - payload = _payload(f"gate-session-{index}", f"gate text {index}") - archive.write_raw_payload( - provider=Provider.CODEX, - payload=payload, - source_path=f"current/{index}.jsonl", - acquired_at_ms=index + 1, - revision=RawRevisionEnvelope( - logical_source_key=f"codex-session:gate-session-{index}", - kind=RawRevisionKind.FULL, - source_revision=sha256(payload).hexdigest(), - acquisition_generation=0, - authority=RawRevisionAuthority.ASSERTED, - ), - ) - # The receipt is intentionally taken from a source tier whose authority - # classification is already settled. Replay must not manufacture a - # baseline or rewrite asserted authority before the first checkpoint, so - # any later mutation of these bindings is a real stale-pass condition. - with sqlite3.connect(root / "source.db") as source: - source.execute("UPDATE raw_sessions SET baseline_raw_id = raw_id, revision_authority = 'byte_proven'") - source.commit() - census = census_historical_revision_evidence(root) - assert census.scanned == count - - -def _active_bytes(root: Path) -> bytes: - return root.joinpath("index.db").read_bytes() - - -def _generation_ids(root: Path) -> set[str]: - generations = root / ".index-generations" - return {path.name for path in generations.glob("gen-*")} if generations.exists() else set() - - -def _same_shard_raw_ids(root: Path) -> tuple[str, ...]: - with sqlite3.connect(root / "source.db") as conn: - raw_ids = [str(row[0]) for row in conn.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id")] - return tuple(next(bucket for bucket in shard_raw_ids(root, raw_ids, 2) if len(bucket) >= 2)) - - -def _raw_ids(root: Path) -> tuple[str, ...]: - with sqlite3.connect(root / "source.db") as conn: - return tuple(str(row[0]) for row in conn.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id")) - - -def _interpose_receipt_mutation(monkeypatch: pytest.MonkeyPatch, receipt_path: Path, *, expire: bool) -> None: - original_acquire = OwnedArchiveLocation.acquire - - def acquire_after_mutation( - cls: type[OwnedArchiveLocation], /, location: object, **kwargs: object - ) -> OwnedArchiveLocation: - owned = original_acquire(location, **kwargs) # type: ignore[arg-type] - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - if expire: - payload["generated_at"] = "2000-01-01T00:00:00Z" - else: - payload["source_snapshot"] = "post-preflight-source-drift" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - return owned - - monkeypatch.setattr(OwnedArchiveLocation, "acquire", classmethod(acquire_after_mutation)) - - -def test_missing_receipt_fails_before_lease_and_candidate_mutation(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - active_before = _active_bytes(root) - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root, promote=True)) - assert _active_bytes(root) == active_before - assert not (root / ".index-generations").exists() - - -def test_nonempty_source_still_requires_schema_inference_receipt_after_ownership( - tmp_path: Path, -) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync(RebuildIndexRequest(archive_root=root)) - - assert not (root / ".index-generations").exists() - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - - -def test_receipt_reference_policy_fails_before_candidate_mutation(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = root / "schema-inference-gate-receipt.json" - write_valid_rebuild_receipt(root, receipt_path) - active_before = _active_bytes(root) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert _active_bytes(root) == active_before - assert not (root / ".index-generations").exists() - - -@pytest.mark.parametrize("expire", [False, True], ids=["external-drift", "receipt-expiry"]) -def test_offline_post_preflight_receipt_change_fails_before_lease_or_candidate_mutation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, expire: bool -) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - _interpose_receipt_mutation(monkeypatch, receipt_path, expire=expire) - lease_path = root / ".index-rebuild.lock" - lease_before = lease_path.read_bytes() if lease_path.exists() else None - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert (lease_path.read_bytes() if lease_path.exists() else None) == lease_before - assert not (root / ".index-generations").exists() - assert not (root / ".index-rebuild-transactions").exists() - - -def test_forged_top_level_pass_cannot_bypass_a_failing_subgate(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["verdict"] = "PASS" - payload["query_results"]["zero-surviving-quarantine"]["passed"] = False - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - active_before = _active_bytes(root) - - with pytest.raises(RuntimeError, match="zero-surviving-quarantine is not PASS"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - assert _active_bytes(root) == active_before - assert not (root / ".index-generations").exists() - - -def test_valid_receipt_allows_real_candidate_acceptance_and_promotion(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - - result = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path, promote=True) - ) - - assert result.status == "replayed" - assert result.transaction is not None - assert result.transaction["status"] == "promoted" - assert result.consumed_evidence["receipt_path"] == str(receipt_path) - assert result.consumed_evidence["source_snapshot"] - assert result.consumed_evidence["external_ground_truth_digest"] - assert IndexGenerationStore.for_archive_root(root).active_pointer.resolve().exists() - - -@pytest.mark.parametrize("selection", ["raw-ids", "only-missing", "max-blob-mb"]) -def test_nonresumable_rebuild_persists_refreshed_inventory_evidence_after_detector_change( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, selection: str -) -> None: - """A nonresumable rebuild carries a metadata refresh to later validations. - - Anti-vacuity: this runs the real offline rebuild entry point and validator. - The real raw-id selection boundary touches the external corpus after the - initial receipt validation. Pre-creation validation then refreshes its - detector token once; every subsequent validation must use that token - instead of scanning the corpus again. - """ - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - origin = receipt["ground_truth_inputs"]["origins"]["codex-session"] - external_path = Path(origin["declared_roots"][0]) / origin["external_inventory"][0]["relative_path"] - - full_inventory_calls = 0 - original_inventory = schema_gate_module._external_inventory - - def counted_inventory(roots: object) -> object: - nonlocal full_inventory_calls - full_inventory_calls += 1 - return original_inventory(roots) # type: ignore[arg-type] - - monkeypatch.setattr(schema_gate_module, "_external_inventory", counted_inventory) - original_select = rebuild_index_module.select_rebuild_raw_ids - inventory_calls_before_refresh: int | None = None - - def select_then_touch(request: RebuildIndexRequest, **kwargs: object) -> tuple[int, list[str], int]: - nonlocal inventory_calls_before_refresh - selected = original_select(request, **kwargs) # type: ignore[arg-type] - stat = external_path.stat() - os.utime(external_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) - inventory_calls_before_refresh = full_inventory_calls - return selected - - monkeypatch.setattr(rebuild_index_module, "select_rebuild_raw_ids", select_then_touch) - if selection == "only-missing": - request = RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - only_missing=True, - promote=False, - ) - elif selection == "max-blob-mb": - request = RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_ids=tuple(_raw_ids(root)), - max_blob_mb=1.0, - promote=False, - ) - else: - request = RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_ids=tuple(_raw_ids(root)), - promote=False, - ) - result = rebuild_index_from_source_sync(request) - - assert result.status == "replayed" - assert inventory_calls_before_refresh is not None - assert full_inventory_calls == inventory_calls_before_refresh + 1, ( - "the refreshed pass token must prevent a second full inventory scan" - ) - candidate_receipt = Path(cast(str, result.generation["index_path"])).parent / "rebuild-receipt.json" - persisted_receipt = json.loads(candidate_receipt.read_text(encoding="utf-8")) - assert persisted_receipt["consumed_evidence"] == result.consumed_evidence - assert ( - persisted_receipt["consumed_evidence"]["external_ground_truth_inventory_token"] - == result.consumed_evidence["external_ground_truth_inventory_token"] - ) - - -def test_resumable_checkpoint_and_pass_receipt_reuse_refreshed_inventory_token( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """One refreshed token remains authoritative through a resumable pass receipt. - - Anti-vacuity: this uses the transaction page selection, checkpoint, and - pass-receipt paths. Replacing the shared provenance context in either - helper makes a second full inventory scan observable after the refresh. - """ - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - origin = receipt["ground_truth_inputs"]["origins"]["codex-session"] - external_path = Path(origin["declared_roots"][0]) / origin["external_inventory"][0]["relative_path"] - - full_inventory_calls = 0 - original_inventory = schema_gate_module._external_inventory - - def counted_inventory(roots: object) -> object: - nonlocal full_inventory_calls - full_inventory_calls += 1 - return original_inventory(roots) # type: ignore[arg-type] - - monkeypatch.setattr(schema_gate_module, "_external_inventory", counted_inventory) - original_next_raw_page = IndexGenerationStore.next_raw_page - inventory_calls_before_refresh: int | None = None - - def select_then_touch(self: IndexGenerationStore, *args: object, **kwargs: object) -> object: - nonlocal inventory_calls_before_refresh - page = original_next_raw_page(self, *args, **kwargs) # type: ignore[arg-type] - if inventory_calls_before_refresh is None: - stat = external_path.stat() - os.utime(external_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) - inventory_calls_before_refresh = full_inventory_calls - return page - - monkeypatch.setattr(IndexGenerationStore, "next_raw_page", select_then_touch) - result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - - assert result.status == "paused" - assert result.transaction is not None - assert inventory_calls_before_refresh is not None - assert full_inventory_calls == inventory_calls_before_refresh + 1 - operation_id = str(result.transaction["operation_id"]) - checkpoint = IndexGenerationStore.for_archive_root(root).load_transaction(operation_id) - assert checkpoint.consumed_evidence == result.consumed_evidence - pass_receipt_path = next((root / ".index-rebuild-transactions" / f"{operation_id}.receipts").glob("pass-*.json")) - persisted_receipt = json.loads(pass_receipt_path.read_text(encoding="utf-8")) - assert ( - persisted_receipt["consumed_evidence"]["external_ground_truth_inventory_token"] - == result.consumed_evidence["external_ground_truth_inventory_token"] - ) - - -@pytest.mark.parametrize("transaction_payload", [None, "{"], ids=["missing", "malformed"]) -def test_invalid_receipt_preserves_provenance_error_when_recovery_state_is_unreadable( - tmp_path: Path, transaction_payload: str | None -) -> None: - """Recovery load failures cannot replace the admission-gate rejection.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(receipt), encoding="utf-8") - operation_id = "unreadable-recovery" - if transaction_payload is not None: - transaction_path = root / ".index-rebuild-transactions" / f"{operation_id}.json" - transaction_path.parent.mkdir() - transaction_path.write_text(transaction_payload, encoding="utf-8") - - with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - ) - ) - - -@pytest.mark.parametrize("metadata", ["transaction", "generation"]) -def test_invalid_receipt_preserves_provenance_error_when_recovery_metadata_is_readable_but_malformed( - tmp_path: Path, metadata: str -) -> None: - """Metadata-shape failures during stale retirement cannot replace the gate error.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), operation_id=f"malformed-{metadata}" - ) - if metadata == "transaction": - metadata_path = root / ".index-rebuild-transactions" / f"{transaction.operation_id}.json" - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - payload["generation_id"] = None - metadata_path.write_text(json.dumps(payload), encoding="utf-8") - else: - metadata_path = Path(store.load(transaction.generation_id).index_path).parent / "generation.json" - payload = json.loads(metadata_path.read_text(encoding="utf-8")) - payload["index_path"] = None - metadata_path.write_text(json.dumps(payload), encoding="utf-8") - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(receipt), encoding="utf-8") - - with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=transaction.operation_id, - ) - ) - - -@pytest.mark.parametrize("anchor_state", ["missing", "poisoned"]) -def test_invalid_resume_marks_transaction_stale_without_repairing_active_anchor( - tmp_path: Path, anchor_state: str -) -> None: - """Stale-retirement bookkeeping cannot mutate active-pointer authority.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id=f"stale-with-{anchor_state}-anchor", - ) - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(receipt), encoding="utf-8") - - anchor = root / ".index-active-pointer" - if anchor_state == "missing": - anchor.unlink() - expected_anchor: bytes | None = None - else: - poisoned_path = Path(store.load(transaction.generation_id).index_path) - anchor.write_text(str(poisoned_path), encoding="utf-8") - expected_anchor = anchor.read_bytes() - - with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=transaction.operation_id, - ) - ) - - assert (anchor.read_bytes() if anchor.exists() else None) == expected_anchor - checkpoint = IndexGenerationStore.for_archive_root(root, repair_anchor=False).load_transaction( - transaction.operation_id - ) - assert checkpoint.status == "stale" - - -def test_invalid_empty_source_resume_cannot_return_success_or_remain_resumable(tmp_path: Path) -> None: - """Initial resume admission still retires an operation when source is empty. - - Anti-vacuity: the production offline rebuild route is given a real - ``IndexRebuildTransaction`` and an invalid explicit receipt. The old - early ``empty-source`` return leaves that transaction resumable and makes - this assertion fail. - """ - root = tmp_path / "archive" - initialize_active_archive_root(root) - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id="invalid-empty-source-resume", - ) - receipt_path = tmp_path / "invalid-receipt.json" - receipt_path.write_text("{}", encoding="utf-8") - - with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=transaction.operation_id, - ) - ) - - checkpoint = IndexGenerationStore.for_archive_root(root, repair_anchor=False).load_transaction( - transaction.operation_id - ) - assert checkpoint.status == "stale" - - -def test_resume_revalidates_external_mapping_before_more_replay(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=True, - ) - ) - assert first.status == "paused" - assert first.transaction is not None - operation_id = str(first.transaction["operation_id"]) - processed_value = first.transaction["processed_raw_count"] - assert isinstance(processed_value, int) - processed_before = processed_value - active_before = _active_bytes(root) - - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - origin = receipt["ground_truth_inputs"]["origins"]["codex-session"] - external_path = Path(origin["declared_roots"][0]) / origin["external_inventory"][0]["relative_path"] - external_path.write_bytes(b"changed external corpus") - - with pytest.raises(RuntimeError, match="external ground-truth corpus changed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - raw_batch_size=1, - promote=True, - ) - ) - transaction = IndexGenerationStore.for_archive_root(root).load_transaction(operation_id) - assert transaction.processed_raw_count == processed_before - assert _active_bytes(root) == active_before - - -def test_mapping_mutation_is_rejected_without_relying_on_aggregate_counts(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - mapping = payload["ground_truth_inputs"]["origins"]["codex-session"]["raw_external_mapping"] - mapping[0]["source_path"] = "stale/source/path.jsonl" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(RuntimeError, match="raw external mapping or inventory"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - assert not (root / ".index-generations").exists() - - -@pytest.mark.parametrize("expire", [False, True], ids=["external-drift", "receipt-expiry"]) -def test_deadline_checkpoint_revalidates_receipt_before_persisting_state( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, expire: bool -) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), operation_id="deadline-checkpoint" - ) - transaction = store.checkpoint_transaction(transaction, status="running", derived_stores_cleared=True) - transaction_path = root / ".index-rebuild-transactions" / "deadline-checkpoint.json" - before = transaction_path.read_bytes() - - async def fail_after_receipt_drift(*args: object, **kwargs: object) -> dict[str, object]: - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - if expire: - payload["generated_at"] = "2000-01-01T00:00:00Z" - else: - payload["source_snapshot"] = "post-preflight-source-drift" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - raise RebuildDeadlineExceededError("synthetic deadline") - - monkeypatch.setattr("polylogue.maintenance.replay.rebuild_index_from_source", fail_after_receipt_drift) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id="deadline-checkpoint", - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - ) - ) - - assert transaction_path.read_bytes() == before - - -def test_sharded_replay_revalidates_expired_receipt_before_candidate_writes( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Shard replay must fail before its first index write and clean scratch generations. - - Anti-vacuity: the real sharded rebuild route calls the real replay engine. The - wrapper expires the receipt immediately before that engine starts. Removing - the shard replay provenance guard lets the engine write the shard candidate, - which makes ``replayed_rows`` nonzero before the route fails later. - """ - root = tmp_path / "archive" - _seed(root, count=4) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - raw_ids = _same_shard_raw_ids(root) - replayed_rows: list[int] = [] - - from polylogue.maintenance import replay as replay_module - - original_replay = cast(Callable[..., Any], replay_module.rebuild_index_from_source) - - async def expire_before_replay(*args: Any, **kwargs: Any) -> dict[str, object]: - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - try: - return cast(dict[str, object], await original_replay(*args, **kwargs)) - finally: - config = cast(Config, args[0]) - index_path = Path(config.db_path) - with sqlite3.connect(index_path) as conn: - replayed_rows.append(int(conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0])) - - monkeypatch.setattr(replay_module, "rebuild_index_from_source", expire_before_replay) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - raw_ids=raw_ids, - promote=False, - shard_count=2, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert replayed_rows == [0] - assert _generation_ids(root) == set() - - -def test_sharded_target_creation_cleans_candidate_when_receipt_expires_after_create( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Receipt failure immediately after target creation cannot strand the target.""" - root = tmp_path / "archive" - _seed(root, count=4) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - raw_ids = _same_shard_raw_ids(root) - original_create = IndexGenerationStore.create - create_count = 0 - - def expire_after_target_create( - store: IndexGenerationStore, *, owner_id: str | None = None, source_snapshot: str - ) -> IndexGeneration: - nonlocal create_count - generation = original_create(store, owner_id=owner_id, source_snapshot=source_snapshot) - create_count += 1 - if create_count == 1: - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - return generation - - monkeypatch.setattr(IndexGenerationStore, "create", expire_after_target_create) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - raw_ids=raw_ids, - promote=False, - shard_count=2, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert create_count == 1 - assert _generation_ids(root) == set() - - -def test_full_source_transaction_creation_cleans_candidate_after_post_create_validation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Full-source setup cleans both durable records after post-create validation fails. - - Anti-vacuity: the production full-source route creates both the inactive - candidate and its transaction before the receipt is expired. Removing the - post-create validation or either cleanup leaves the captured generation or - transaction record behind. - """ - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_create_transaction = IndexGenerationStore.create_transaction - created_records: list[tuple[str, str]] = [] - - def expire_after_transaction( - store: IndexGenerationStore, - *, - source_snapshot: str, - operation_id: str | None = None, - pass_byte_budget: int | None = None, - pass_deadline_ms: int | None = None, - consumed_evidence: dict[str, object] | None = None, - ) -> IndexRebuildTransaction: - transaction = original_create_transaction( - store, - source_snapshot=source_snapshot, - operation_id=operation_id, - pass_byte_budget=pass_byte_budget, - pass_deadline_ms=pass_deadline_ms, - consumed_evidence=consumed_evidence, - ) - assert store.load(transaction.generation_id).state == "inactive" - assert store.load_transaction(transaction.operation_id).operation_id == transaction.operation_id - created_records.append((transaction.generation_id, transaction.operation_id)) - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - return transaction - - monkeypatch.setattr(IndexGenerationStore, "create_transaction", expire_after_transaction) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert len(created_records) == 1 - assert _generation_ids(root) == set() - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - - -def test_full_source_snapshot_mismatch_after_transaction_creation_cleans_candidate( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A fresh transaction cannot be retained when its source snapshot drifts.""" - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_create = rebuild_index_module._create_rebuild_transaction_after_receipt_validation - - def create_then_drift(*args: Any, **kwargs: Any) -> IndexRebuildTransaction: - transaction = original_create(*args, **kwargs) - with sqlite3.connect(root / "source.db") as conn: - conn.execute("UPDATE raw_sessions SET source_path = source_path || '.drifted'") - write_valid_rebuild_receipt(root, receipt_path) - return transaction - - monkeypatch.setattr(rebuild_index_module, "_create_rebuild_transaction_after_receipt_validation", create_then_drift) - - with pytest.raises(RuntimeError, match="source evidence changed since this rebuild was planned"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert _generation_ids(root) == set() - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - - -def test_full_source_snapshot_mismatch_after_replay_cleans_transaction_and_candidate( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A source drift detected after replay uses the fresh-transaction cleanup path.""" - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - - def planner_boundary_then_drift(*, processed_before: int | None, processed_after: int) -> bool: - with sqlite3.connect(root / "source.db") as conn: - conn.execute("UPDATE raw_sessions SET source_path = source_path || '.drifted'") - write_valid_rebuild_receipt(root, receipt_path) - return False - - monkeypatch.setattr( - rebuild_index_module, - "_should_refresh_generation_planner_statistics", - planner_boundary_then_drift, - ) - - with pytest.raises(RuntimeError, match="source evidence changed during this bounded rebuild pass"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - assert _generation_ids(root) == set() - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - - -def test_full_source_provenance_cleanup_reports_failed_discards( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A cleanup actuator failure is diagnostic while the mismatch stays primary.""" - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_create = rebuild_index_module._create_rebuild_transaction_after_receipt_validation - - def create_then_drift(*args: Any, **kwargs: Any) -> IndexRebuildTransaction: - transaction = original_create(*args, **kwargs) - with sqlite3.connect(root / "source.db") as conn: - conn.execute("UPDATE raw_sessions SET source_path = source_path || '.drifted'") - write_valid_rebuild_receipt(root, receipt_path) - return transaction - - monkeypatch.setattr(rebuild_index_module, "_create_rebuild_transaction_after_receipt_validation", create_then_drift) - monkeypatch.setattr(IndexGenerationStore, "discard_if_inactive", lambda *args, **kwargs: False) - monkeypatch.setattr(IndexGenerationStore, "discard_transaction", lambda *args, **kwargs: False) - - with pytest.raises(RuntimeError, match="source evidence changed since this rebuild was planned") as raised: - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path) - ) - - notes = "\n".join(raised.value.__notes__ or ()) - assert "rebuild transaction cleanup also failed" in notes - assert "was not discarded" in notes - - -@pytest.mark.parametrize("failure_kind", ["replay", "readiness"], ids=["replay-failure", "readiness-failure"]) -def test_nonresumable_failure_discards_inactive_candidate( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_kind: str -) -> None: - """One-shot rebuild failures cannot leave an inactive candidate behind. - - Anti-vacuity: both cases enter the production nonresumable raw-id route and - create a real SQLite generation. The replay case fails inside the real - replay call, while the readiness case completes replay and fails at the - terminal readiness gate. Removing the explicit cleanup leaves ``gen-*`` - metadata behind. - """ - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - if failure_kind == "replay": - - async def fail_replay(*args: Any, **kwargs: Any) -> dict[str, object]: - raise RuntimeError("synthetic nonresumable replay failure") - - monkeypatch.setattr("polylogue.maintenance.replay.rebuild_index_from_source", fail_replay) - expected = "synthetic nonresumable replay failure" - else: - monkeypatch.setattr( - "polylogue.storage.archive_readiness.archive_readiness_status", - lambda root: { - "checked": True, - "blocked_surface_count": 1, - "surfaces": {"synthetic": {"ready": False}}, - }, - ) - expected = "is not exact-ready" - - with pytest.raises(RuntimeError, match=expected): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - raw_ids=_raw_ids(root), - promote=False, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert _generation_ids(root) == set() - - -def test_capture_evidence_mutation_after_receipt_rejects_before_promotion( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Capture evidence drift after receipt production cannot be promoted. - - Anti-vacuity: the real full-source promote route replays into an inactive - generation before the patched production replay boundary mutates capture - mode, capture index, file metadata, and capture observations in source.db. - The canonical source-evidence snapshot rejects the candidate before the - activation swap and cleans its transaction and generation. - """ - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - active_before = _active_bytes(root) - from polylogue.maintenance import replay as replay_module - - real_replay = cast(Callable[..., Any], replay_module.rebuild_index_from_source) - - async def mutate_capture_evidence(*args: object, **kwargs: object) -> dict[str, object]: - replay = await real_replay(*args, **kwargs) - with sqlite3.connect(root / "source.db") as conn: - raw_id = str(conn.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id LIMIT 1").fetchone()[0]) - conn.execute( - "UPDATE raw_sessions SET capture_mode = 'gemini', source_index = source_index + 1, " - "file_mtime_ms = COALESCE(file_mtime_ms, 0) + 1 WHERE raw_id = ?", - (raw_id,), - ) - conn.execute( - "INSERT OR IGNORE INTO raw_capture_observations " - "(raw_id, capture_mode, first_observed_at_ms) VALUES (?, 'gemini', 999999)", - (raw_id,), - ) - return cast(dict[str, object], replay) - - monkeypatch.setattr(replay_module, "rebuild_index_from_source", mutate_capture_evidence) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - schema_inference_receipt_path=receipt_path, - ) - ) - - assert _active_bytes(root) == active_before - assert _generation_ids(root) == set() - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - - -def test_sharded_graph_drift_blocks_derived_stages_and_cleans_resumable_state( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Receipt drift after graph resolution cannot reach derived-state writers.""" - root = tmp_path / "archive" - _seed(root, count=4) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_graph_resolution = sharded_rebuild_module.resolve_cross_shard_session_graph - original_create = IndexGenerationStore.create - original_discard = IndexGenerationStore.discard_if_inactive - created_generation_ids: list[str] = [] - discard_calls: list[str] = [] - repopulate_calls: list[Path] = [] - insight_calls: list[object] = [] - source_write_lock = threading.Lock() - - from polylogue.sources import revision_backfill as revision_backfill_module - - original_backfill = revision_backfill_module.backfill_historical_revision_evidence - - def serialize_backfill(*args: Any, **kwargs: Any) -> object: - # Each replay keeps its archive source transaction open while it - # records parser census and revision evidence on source.db. Serialize - # the existing real route here so the regression remains about the - # provenance boundary instead of SQLite's unrelated concurrent-writer - # behavior. - with source_write_lock: - return original_backfill(*args, **kwargs) - - monkeypatch.setattr(revision_backfill_module, "backfill_historical_revision_evidence", serialize_backfill) - - def drift_after_graph_resolution(*args: Any, **kwargs: Any) -> float: - graph_elapsed_s = original_graph_resolution(*args, **kwargs) - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - return graph_elapsed_s - - def record_create( - store: IndexGenerationStore, *, owner_id: str | None = None, source_snapshot: str - ) -> IndexGeneration: - generation = original_create(store, owner_id=owner_id, source_snapshot=source_snapshot) - created_generation_ids.append(generation.generation_id) - return generation - - def record_discard(store: IndexGenerationStore, generation: IndexGeneration) -> bool: - discard_calls.append(generation.generation_id) - return original_discard(store, generation) - - def unexpected_repopulate(index_path: Path) -> dict[str, float]: - repopulate_calls.append(index_path) - raise AssertionError("stale provenance reached bulk-derived repopulation") - - def unexpected_insight_repair(*args: Any, **kwargs: Any) -> object: - insight_calls.append((args, kwargs)) - raise AssertionError("stale provenance reached session insight repair") - - monkeypatch.setattr(sharded_rebuild_module, "resolve_cross_shard_session_graph", drift_after_graph_resolution) - monkeypatch.setattr(IndexGenerationStore, "create", record_create) - monkeypatch.setattr(IndexGenerationStore, "discard_if_inactive", record_discard) - monkeypatch.setattr(rebuild_index_module, "_repopulate_bulk_build_derived_state", unexpected_repopulate) - monkeypatch.setattr("polylogue.storage.repair.repair_session_insights", unexpected_insight_repair) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - shard_count=2, - ) - ) - - assert repopulate_calls == [] - assert insight_calls == [] - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - shard_ids = set(created_generation_ids[1:]) - assert shard_ids - assert shard_ids <= set(discard_calls) - assert len([generation_id for generation_id in discard_calls if generation_id in shard_ids]) == len(shard_ids) - assert _generation_ids(root) == set() - - -def test_revision_authority_binding_stales_a_resumed_real_rebuild( - tmp_path: Path, -) -> None: - """A replay-affecting raw authority mutation cannot cross a page boundary. - - Anti-vacuity: removing the authority fields from the source binding leaves - the transaction paused instead of stale, failing the terminal assertion. - """ - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - assert first.status == "paused" - assert first.transaction is not None - operation_id = str(first.transaction["operation_id"]) - before_evidence_digest = rebuild_source_evidence_snapshot(root) - with sqlite3.connect(root / "source.db") as source: - source.execute( - "UPDATE raw_sessions SET revision_authority_evidence = 'live_source_verification_v1' " - "WHERE raw_id = (SELECT raw_id FROM raw_sessions ORDER BY raw_id LIMIT 1)" - ) - source.commit() - assert rebuild_source_evidence_snapshot(root) != before_evidence_digest - - with pytest.raises(RuntimeError, match="source snapshot does not match"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - raw_batch_size=1, - promote=False, - ) - ) - transaction = IndexGenerationStore.for_archive_root(root).load_transaction(operation_id) - assert transaction.status == "stale" - - -def test_blob_bytes_changed_after_replay_are_rejected_before_candidate_readiness( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The final real readiness route verifies bytes, not only source.db hashes. - - Anti-vacuity: removing the readiness blob binding removes the dedicated - integrity failure asserted here, even though source.db still names a - parseable row. - """ - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_repopulate = rebuild_index_module._repopulate_bulk_build_derived_state - - def corrupt_after_replay(index_path: Path) -> dict[str, float]: - timings = original_repopulate(index_path) - with sqlite3.connect(root / "source.db") as source: - blob_hash = bytes(source.execute("SELECT blob_hash FROM raw_sessions LIMIT 1").fetchone()[0]).hex() - BlobStore(root / "blob").blob_path(blob_hash).write_bytes(b"parseable but corrupted") - return timings - - monkeypatch.setattr(rebuild_index_module, "_repopulate_bulk_build_derived_state", corrupt_after_replay) - with pytest.raises(RuntimeError, match="referenced source blob integrity verification failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - promote=False, - ) - ) - assert not list((root / ".index-generations").glob("gen-*")) - - -def test_pointer_flip_records_post_promotion_attestation_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A post-flip checkpoint fault leaves an active, terminally classified operation. - - Anti-vacuity: removing the terminal attestation transition makes the - injected post-flip failure escape instead of returning an active failed - attestation. - """ - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_checkpoint = IndexGenerationStore.checkpoint_transaction - - def fail_promoted_checkpoint(self: IndexGenerationStore, transaction: object, **kwargs: object) -> object: - if kwargs.get("status") == "promoted": - raise OSError("simulated post-promotion attestation failure") - return original_checkpoint(self, transaction, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(IndexGenerationStore, "checkpoint_transaction", fail_promoted_checkpoint) - with pytest.raises(OSError, match="simulated post-promotion attestation failure"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path, promote=True) - ) - - store = IndexGenerationStore.for_archive_root(root) - operation_id = next(path.stem for path in store.transactions_root.glob("*.json")) - transaction = store.load_transaction(operation_id) - assert transaction.status == "promoted-attestation-failed" - attestation = transaction.post_promotion_attestation - assert isinstance(attestation, dict) - assert attestation["status"] == "failed" - assert store.load(transaction.generation_id).state == "active" - assert store.active_pointer.resolve(strict=True) == Path(store.load(transaction.generation_id).index_path).resolve( - strict=True - ) - - -def test_daemon_reconciles_active_generation_after_both_attestation_checkpoints_fail( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - seeded_transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id=bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID, - ) - original_checkpoint = IndexGenerationStore.checkpoint_transaction - - def fail_attestation_checkpoint(self: IndexGenerationStore, transaction: object, **kwargs: object) -> object: - if kwargs.get("status") in {"promoted", "promoted-attestation-failed"}: - raise OSError("simulated double attestation checkpoint failure") - return original_checkpoint(self, transaction, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(IndexGenerationStore, "checkpoint_transaction", fail_attestation_checkpoint) - with pytest.raises(OSError, match="simulated double attestation checkpoint failure"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID, - promote=True, - ) - ) - - transaction = store.load_transaction(bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID) - assert transaction.status == "ready" - assert transaction.generation_id == seeded_transaction.generation_id - assert store.load(transaction.generation_id).state == "active" - - monkeypatch.undo() - reconciled = bulk_rebuild_module.resolve_or_start_daemon_bulk_rebuild_transaction( - root, - schema_inference_receipt_path=receipt_path, - ) - - assert reconciled.status == "promoted-attestation-failed" - assert reconciled.post_promotion_attestation == { - "status": "reconciled-after-restart", - "generation_id": transaction.generation_id, - "generation_state": "active", - } - - -def test_offline_retry_reconciles_active_generation_after_both_attestation_checkpoints_fail( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """An operator retry cannot resume a generation that was already promoted. - - Anti-vacuity: the setup drives the real offline synchronous rebuild through - the pointer flip while both post-promotion transaction writes fail. The - retry uses that same public offline entry point, not the daemon resolver. - Removing its transaction reconciliation leaves the persisted transaction - ``ready`` alongside the active generation. - """ - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - operation_id = "operator-created-post-promotion-failure" - store = IndexGenerationStore.for_archive_root(root) - store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id=operation_id, - ) - original_checkpoint = IndexGenerationStore.checkpoint_transaction - - def fail_attestation_checkpoint(self: IndexGenerationStore, transaction: object, **kwargs: object) -> object: - if kwargs.get("status") in {"promoted", "promoted-attestation-failed"}: - raise OSError("simulated double offline attestation checkpoint failure") - return original_checkpoint(self, transaction, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(IndexGenerationStore, "checkpoint_transaction", fail_attestation_checkpoint) - request = RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - promote=True, - ) - with pytest.raises(OSError, match="simulated double offline attestation checkpoint failure"): - rebuild_index_from_source_sync(request) - - stranded = store.load_transaction(operation_id) - assert stranded.status == "ready" - assert store.load(stranded.generation_id).state == "active" - - monkeypatch.undo() - with pytest.raises(RuntimeError, match=f"rebuild operation {operation_id} is promoted-attestation-failed"): - rebuild_index_from_source_sync(request) - - reconciled = store.load_transaction(operation_id) - assert reconciled.status == "promoted-attestation-failed" - assert reconciled.generation_id == stranded.generation_id - assert store.load(reconciled.generation_id).state == "active" - - -def test_provenance_failure_reconciles_active_generation_before_stale_retirement(tmp_path: Path) -> None: - """Receipt rejection preserves an already-promoted owned generation's lifecycle fact.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), operation_id="active-before-stale-retirement" - ) - active_generation = store.promote(store.load(transaction.generation_id)) - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - receipt["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(receipt), encoding="utf-8") - - with pytest.raises(rebuild_index_module.RebuildProvenanceError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=transaction.operation_id, - promote=True, - ) - ) - - reconciled = store.load_transaction(transaction.operation_id) - assert reconciled.status == "promoted-attestation-failed" - assert reconciled.generation_id == active_generation.generation_id - assert store.load(reconciled.generation_id).state == "active" - - -def test_active_generation_reconciliation_requires_transaction_owner_match( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A stale transaction cannot checkpoint a generation owned by another pass.""" - root = tmp_path / "archive" - _seed(root, count=1) - store = IndexGenerationStore.for_archive_root(root) - generation = IndexGeneration( - generation_id="gen-active", - owner_id="current-owner", - archive_root=str(root), - index_path=str(root / "index.db"), - state="active", - created_at_ms=1, - ) - transaction = IndexRebuildTransaction( - operation_id="rebuild-stale-owner", - generation_id=generation.generation_id, - generation_owner_id="stale-owner", - source_snapshot="source-snapshot", - status="ready", - created_at_ms=1, - updated_at_ms=1, - ) - monkeypatch.setattr(store, "load", lambda _generation_id: generation) - - # Pin the non-owner short circuits so the owner mismatch is the only - # reason reconciliation can return the unchanged transaction. - assert generation.state == "active" - assert store.active_pointer.resolve(strict=True) == Path(generation.index_path).resolve(strict=True) - - def fail_checkpoint(*args: object, **kwargs: object) -> object: - raise AssertionError("owner-mismatched transaction must not checkpoint") - - monkeypatch.setattr(store, "checkpoint_transaction", fail_checkpoint) - - reconciled = rebuild_index_module._reconcile_active_generation_transaction(store, transaction) - - assert reconciled == transaction - - -def test_daemon_does_not_route_promoted_attestation_failure_back_to_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A terminal active operation is not handed to the rebuild engine again.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id=bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID, - ) - original_checkpoint = IndexGenerationStore.checkpoint_transaction - - def fail_promoted_checkpoint(self: IndexGenerationStore, transaction: object, **kwargs: object) -> object: - if kwargs.get("status") == "promoted": - raise OSError("simulated post-promotion attestation failure") - return original_checkpoint(self, transaction, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(IndexGenerationStore, "checkpoint_transaction", fail_promoted_checkpoint) - with pytest.raises(OSError, match="simulated post-promotion attestation failure"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID, - promote=True, - ) - ) - terminal = store.load_transaction(bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID) - assert terminal.status == "promoted-attestation-failed" - assert bulk_rebuild_module.has_resumable_daemon_bulk_rebuild_transaction(root) is False - - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - rebuild_called = False - - def unexpected_rebuild(*args: object, **kwargs: object) -> object: - nonlocal rebuild_called - rebuild_called = True - raise AssertionError("terminal daemon operation was routed back to rebuild") - - monkeypatch.setattr(rebuild_index_module, "rebuild_index_from_source_sync", unexpected_rebuild) - - class _AdmissionReadyParseStage: - def writer_admission_ready(self) -> bool: - return True - - result = asyncio.run( - bulk_rebuild_module.run_daemon_bulk_rebuild_pass( - config=Config(archive_root=root, render_root=root / "render", sources=[]), - parse_stage=cast(Any, _AdmissionReadyParseStage()), - max_payload_bytes=1, - ) - ) - assert result is None - assert rebuild_called is False - active_generation = store.load(terminal.generation_id) - assert store.active_pointer.resolve(strict=True) == Path(active_generation.index_path).resolve(strict=True) - - -def test_daemon_retires_attestation_failure_after_source_drift(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A changed source gets a fresh daemon transaction without replacing the active index.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - store = IndexGenerationStore.for_archive_root(root) - terminal = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - operation_id=bulk_rebuild_module.DAEMON_BULK_REBUILD_OPERATION_ID, - ) - store.promote(store.load(terminal.generation_id)) - store.checkpoint_transaction(terminal, status="promoted-attestation-failed") - monkeypatch.setattr(bulk_rebuild_module, "rebuild_source_evidence_snapshot", lambda _root: "changed-source") - - replacement = bulk_rebuild_module.resolve_or_start_daemon_bulk_rebuild_transaction( - root, schema_inference_receipt_path=receipt_path - ) - - assert replacement.status == "running" - assert replacement.generation_id != terminal.generation_id - assert replacement.source_snapshot == "changed-source" - assert store.load(terminal.generation_id).state == "active" - - -def test_validation_rejection_cannot_stale_transaction_before_ownership( - tmp_path: Path, -) -> None: - """A live archive owner rejects the invocation before transaction mutation.""" - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - assert first.transaction is not None - operation_id = str(first.transaction["operation_id"]) - store = IndexGenerationStore.for_archive_root(root) - before = store.load_transaction(operation_id) - - owner = OwnedArchiveLocation.acquire(ArchiveLocation.resolve(root)) - try: - with pytest.raises(ArchiveOwnershipError): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - raw_batch_size=1, - promote=False, - ) - ) - finally: - owner.release() - - assert store.load_transaction(operation_id) == before - - -@pytest.mark.parametrize( - "exception_type", - [KeyboardInterrupt, asyncio.CancelledError, SystemExit], - ids=["keyboard-interrupt", "cancelled", "control-flow-base-exception"], -) -def test_validation_control_flow_does_not_change_resumability( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, exception_type: type[BaseException] -) -> None: - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - assert first.transaction is not None - operation_id = str(first.transaction["operation_id"]) - - def raise_control_flow(*args: object, **kwargs: object) -> object: - raise exception_type("validation interrupted") - - monkeypatch.setattr(rebuild_index_module, "_validate_rebuild_provenance_receipt", raise_control_flow) - with pytest.raises(exception_type, match="validation interrupted"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - raw_batch_size=1, - promote=False, - ) - ) - assert IndexGenerationStore.for_archive_root(root).load_transaction(operation_id).status == "paused" - - -def test_external_rewrite_with_preserved_size_inode_and_mtime_is_rehashed( - tmp_path: Path, -) -> None: - root = tmp_path / "archive" - _seed(root, count=2) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - first = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=1, - promote=False, - ) - ) - assert first.transaction is not None - operation_id = str(first.transaction["operation_id"]) - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - origin = receipt["ground_truth_inputs"]["origins"]["codex-session"] - external_path = Path(origin["declared_roots"][0]) / origin["external_inventory"][0]["relative_path"] - before = external_path.stat() - original = external_path.read_bytes() - replacement = bytes(byte ^ 0xFF for byte in original) - external_path.write_bytes(replacement) - os.utime(external_path, ns=(before.st_atime_ns, before.st_mtime_ns)) - after = external_path.stat() - assert after.st_ino == before.st_ino - assert after.st_size == before.st_size - assert after.st_mtime_ns == before.st_mtime_ns - assert replacement != original - - with pytest.raises(RuntimeError, match="external ground-truth corpus changed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - operation_id=operation_id, - raw_batch_size=1, - promote=False, - ) - ) - - -def test_full_blob_verification_supplies_referenced_snapshot_without_rehash( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - _seed(root, count=1) - with sqlite3.connect(root / "source.db") as source: - referenced_hashes = {bytes(row[0]).hex() for row in source.execute("SELECT blob_hash FROM raw_sessions")} - verify_calls = 0 - original_verify = BlobStore.verify - - def count_verify(self: BlobStore, blob_hash: str) -> bool: - nonlocal verify_calls - verify_calls += 1 - return original_verify(self, blob_hash) - - monkeypatch.setattr(BlobStore, "verify", count_verify) - evidence = schema_gate_module._full_blob_hash_evidence(root, referenced_hashes=referenced_hashes) - assert evidence["passed"] is True - assert verify_calls == 0 - snapshot = cast(dict[str, object], evidence["referenced_blob_integrity_snapshot"]) - assert snapshot["verifier"] == "polylogue.storage.blob_store.BlobStore.verify_all" - assert snapshot["passed"] is True - - -def test_rebuild_reuses_verified_blob_snapshot_across_readiness_boundaries( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The two production readiness checks share one byte-verification result.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - calls = 0 - original_snapshot = schema_gate_module._referenced_blob_integrity_snapshot - - def count_snapshot(*args: object, **kwargs: object) -> dict[str, object]: - nonlocal calls - calls += 1 - return original_snapshot(*args, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(schema_gate_module, "_referenced_blob_integrity_snapshot", count_snapshot) - result = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path, promote=False) - ) - assert result.status == "replayed" - assert calls == 2 - - -def test_rebuild_rechecks_blob_bytes_at_final_readiness_boundary( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Corruption after insight materialization is caught before readiness.""" - root = tmp_path / "archive" - _seed(root, count=1) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - from polylogue.storage import repair as repair_module - - original_repair = repair_module.repair_session_insights - - def corrupt_after_insights(*args: object, **kwargs: object) -> object: - result = cast(Any, original_repair)(*args, **kwargs) - with sqlite3.connect(root / "source.db") as source: - blob_hash = bytes(source.execute("SELECT blob_hash FROM raw_sessions LIMIT 1").fetchone()[0]).hex() - BlobStore(root / "blob").blob_path(blob_hash).write_bytes(b"corrupted after readiness precursor") - return result - - monkeypatch.setattr("polylogue.storage.repair.repair_session_insights", corrupt_after_insights) - with pytest.raises(RuntimeError, match="referenced source blob integrity verification failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=receipt_path, promote=False) - ) - - -def test_replay_closure_caches_fingerprints_per_origin(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Replay evidence computes parser and lowering fingerprints once per closure.""" - root = tmp_path / "archive" - _seed(root, count=2) - lower_calls = 0 - parser_calls: list[str] = [] - original_lowering = origin_specs_module.lowering_fingerprint - original_parser = origin_specs_module.parser_fingerprint_for_origin - - def count_lowering() -> str: - nonlocal lower_calls - lower_calls += 1 - return original_lowering() - - def count_parser(origin: str) -> str: - parser_calls.append(origin) - return original_parser(origin) - - monkeypatch.setattr(origin_specs_module, "lowering_fingerprint", count_lowering) - monkeypatch.setattr(origin_specs_module, "parser_fingerprint_for_origin", count_parser) - evidence = rebuild_index_module._rebuild_replay_closure_evidence(root, _raw_ids(root)) - assert evidence["raw_session_evidence"] - assert lower_calls == 1 - assert parser_calls == ["codex-session"] - - -def test_referenced_blob_snapshot_ignores_volatile_filesystem_metadata( - tmp_path: Path, -) -> None: - """Identical content remains valid when a blob's mtime changes. - - Anti-vacuity: restoring the old inode/mtime fields to the content snapshot - would make this metadata-only touch change the recorded evidence. - """ - root = tmp_path / "archive" - _seed(root, count=1) - with sqlite3.connect(root / "source.db") as source: - referenced_hashes = {bytes(row[0]).hex() for row in source.execute("SELECT blob_hash FROM raw_sessions")} - before = schema_gate_module._referenced_blob_integrity_snapshot(root, referenced_hashes=referenced_hashes) - blob_path = BlobStore(root / "blob").blob_path(next(iter(referenced_hashes))) - stat = blob_path.stat() - os.utime(blob_path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1)) - after = schema_gate_module._referenced_blob_integrity_snapshot(root, referenced_hashes=referenced_hashes) - assert all( - "inode" not in entry and "mtime_ns" not in entry for entry in cast(list[dict[str, object]], before["entries"]) - ) - assert after == before - - -def test_source_evidence_snapshot_streams_raw_session_rows(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - root = tmp_path / "archive" - _seed(root, count=2) - sqlite_module = cast(Any, index_generation_module).sqlite3 - real_connect = sqlite_module.connect - - class GuardedCursor: - def __init__(self, cursor: sqlite3.Cursor, sql: str) -> None: - self._cursor = cursor - self._sql = sql - - def __iter__(self) -> GuardedCursor: - return self - - def __next__(self) -> tuple[object, ...]: - return next(self._cursor) - - def fetchall(self) -> list[tuple[object, ...]]: - if "FROM RAW_SESSIONS" in self._sql.upper(): - raise AssertionError("raw_sessions evidence must be consumed as a stream") - return self._cursor.fetchall() - - def __getattr__(self, name: str) -> object: - return getattr(self._cursor, name) - - class GuardedConnection: - def __init__(self, connection: sqlite3.Connection) -> None: - self._connection = connection - - def execute(self, sql: str, parameters: object = ()) -> GuardedCursor: - return GuardedCursor(self._connection.execute(sql, cast(Any, parameters)), sql) - - def __getattr__(self, name: str) -> object: - return getattr(self._connection, name) - - def guarded_connect(*args: object, **kwargs: object) -> GuardedConnection: - return GuardedConnection(real_connect(*args, **kwargs)) - - monkeypatch.setattr(sqlite_module, "connect", guarded_connect) - assert index_generation_module.rebuild_source_evidence_snapshot(root) diff --git a/tests/unit/maintenance/test_rebuild_index_resume_correctness.py b/tests/unit/maintenance/test_rebuild_index_resume_correctness.py deleted file mode 100644 index 30b2467e5e..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_resume_correctness.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Real raw-replay recovery proof for ``polylogue-b5l.1``. - -The route under test is the production offline rebuild orchestrator. The -fixture interrupts immediately after the first replay page is durably -checkpointed, then resumes the same candidate and compares its promoted -semantic output with an independently clean rebuild. It deliberately checks -rows, topology, public FTS reads, and insight materialization rather than a -test-only cursor counter. -""" - -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path -from typing import cast - -import pytest - -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.index_generation import IndexGenerationStore -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - - -class InjectedInterruptError(RuntimeError): - """Test-only process-death analogue raised after a durable checkpoint.""" - - -def _payload(native_id: str, text: str, *, parent_native_id: str | None = None) -> bytes: - cwd = "/realm/project/resume-fixture" - rows: list[dict[str, object]] = [ - { - "type": "session_meta", - "payload": {"id": native_id, "timestamp": "2026-08-04T12:00:00Z", "cwd": cwd}, - } - ] - if parent_native_id is not None: - rows.append( - { - "type": "session_meta", - "payload": {"id": parent_native_id, "timestamp": "2026-08-04T11:00:00Z", "cwd": cwd}, - } - ) - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }, - }, - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed(root: Path, *, monkeypatch: pytest.MonkeyPatch) -> None: - initialize_active_archive_root(root) - packages = [] - for index, native_id, parent_native_id in ( - (0, "resume-parent", None), - (1, "resume-child", "resume-parent"), - (2, "resume-standalone", None), - ): - path = root / "wire" / "resume" / f"{index}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(_payload(native_id, f"resume-token-{index}", parent_native_id=parent_native_id)) - packages.append(provider_source_package("codex", (path,))) - result = admit_provider_source_packages(root, packages) - assert getattr(result, "parse_failures", 0) == 0 - backfill_historical_revision_evidence(root, ingest_workers=1) - receipt_path = write_valid_rebuild_receipt(root, root.parent / f"{root.name}-schema-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - - -def _semantic_snapshot(root: Path) -> tuple[object, ...]: - with sqlite3.connect(root / "index.db") as conn: - rows = tuple( - tuple(conn.execute(query)) - for query in ( - "SELECT session_id, message_count, content_hash FROM sessions ORDER BY session_id", - "SELECT message_id, session_id, role, position FROM messages ORDER BY message_id", - "SELECT block_id, message_id, block_type, text FROM blocks ORDER BY block_id", - "SELECT src_session_id, dst_origin, dst_native_id, link_type, resolved_dst_session_id " - "FROM session_links ORDER BY src_session_id, dst_origin, dst_native_id, link_type", - "SELECT session_id, message_count, materializer_version FROM session_profiles ORDER BY session_id", - "SELECT insight_type, session_id, materializer_version " - "FROM insight_materialization ORDER BY insight_type, session_id", - ) - ) - with ArchiveStore.open_existing(root, read_only=True) as archive: - fts = tuple( - (f"resume-token-{index}", tuple(archive.search_blocks(f"resume-token-{index}"))) for index in range(3) - ) - return (*rows, fts) - - -def test_committed_page_interrupt_resumes_only_suffix_and_matches_clean_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "resumed" - clean_root = tmp_path / "clean" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed(root, monkeypatch=monkeypatch) - resumed_receipt_path = write_valid_rebuild_receipt(root, tmp_path / "resumed-receipt.json") - - original_checkpoint = IndexGenerationStore.checkpoint_transaction - interrupted = False - - def interrupt_after_committed_page(self: IndexGenerationStore, transaction: object, **kwargs: object) -> object: - nonlocal interrupted - checkpointed = original_checkpoint(self, transaction, **kwargs) # type: ignore[arg-type] - if not interrupted and kwargs.get("processed_raw_count") == 1: - interrupted = True - raise InjectedInterruptError("simulated process interruption after committed page") - return checkpointed - - with monkeypatch.context() as scoped: - scoped.setattr(IndexGenerationStore, "checkpoint_transaction", interrupt_after_committed_page) - with pytest.raises(InjectedInterruptError, match="after committed page"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - raw_batch_size=1, - schema_inference_receipt_path=resumed_receipt_path, - ) - ) - - store = IndexGenerationStore.for_archive_root(root) - operation_id = next(path.stem for path in store.transactions_root.glob("*.json")) - transaction = store.load_transaction(operation_id) - assert transaction.processed_raw_count == 1 - assert transaction.cursor is not None - with sqlite3.connect(Path(store.load(transaction.generation_id).index_path)) as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 1 - - # Each resumed pass can schedule only rows beyond the committed source - # cursor. Recording the real replay call catches a cursor that merely - # reports progress while restarting from page one. - import polylogue.maintenance.replay as replay_module - - replayed_raw_pages: list[tuple[str, ...]] = [] - real_replay = replay_module.rebuild_index_from_source - - async def recording_replay(*args: object, **kwargs: object) -> dict[str, object]: - replayed_raw_pages.append(tuple(cast(list[str], kwargs["raw_ids"]))) - return await real_replay(*args, **kwargs) # type: ignore[arg-type] - - monkeypatch.setattr(replay_module, "rebuild_index_from_source", recording_replay) - while True: - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=operation_id, - raw_batch_size=1, - schema_inference_receipt_path=resumed_receipt_path, - ) - ) - if receipt.status == "replayed": - break - assert receipt.status == "paused" - - assert [len(page) for page in replayed_raw_pages] == [1, 1] - assert len({raw_id for page in replayed_raw_pages for raw_id in page}) == 2 - with sqlite3.connect(root / "source.db") as conn: - first_committed_raw_id = str( - conn.execute("SELECT raw_id FROM raw_sessions ORDER BY blob_hash, raw_id LIMIT 1").fetchone()[0] - ) - all_raw_ids = {str(row[0]) for row in conn.execute("SELECT raw_id FROM raw_sessions")} - resumed_raw_ids = {raw_id for page in replayed_raw_pages for raw_id in page} - # Mutation that resets the persisted cursor would replay the committed raw - # again and fail this exact suffix conservation check. - assert first_committed_raw_id not in resumed_raw_ids - assert resumed_raw_ids == all_raw_ids - {first_committed_raw_id} - assert receipt.operation["cursor"] is not None - assert receipt.operation["heartbeat"]["at_ms"] is not None # type: ignore[index] - assert receipt.operation["recovery_state"] == "promoted" - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(clean_root)) - _seed(clean_root, monkeypatch=monkeypatch) - clean_receipt_path = write_valid_rebuild_receipt(clean_root, tmp_path / "clean-receipt.json") - clean = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=clean_root, raw_batch_size=1, schema_inference_receipt_path=clean_receipt_path) - ) - assert clean.status == "paused" - assert clean.transaction is not None - clean_operation = clean.transaction["operation_id"] - assert isinstance(clean_operation, str) - while clean.status != "replayed": - clean = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=clean_root, - operation_id=clean_operation, - raw_batch_size=1, - schema_inference_receipt_path=clean_receipt_path, - ) - ) - - resumed_snapshot = _semantic_snapshot(root) - assert resumed_snapshot[3], "the real replay fixture must exercise a lineage link" - assert resumed_snapshot == _semantic_snapshot(clean_root) diff --git a/tests/unit/maintenance/test_rebuild_index_selection.py b/tests/unit/maintenance/test_rebuild_index_selection.py deleted file mode 100644 index 627a3a12a9..0000000000 --- a/tests/unit/maintenance/test_rebuild_index_selection.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Source-row selection helpers in ``maintenance/rebuild_index.py`` (polylogue-ogn1). - -Covers the CodeRabbit findings on PR #3076's rebuild-index coordination that -were still genuinely present against current source: - -- ``missing_index_raw_ids`` must treat every source row as missing when - ``index.db`` does not exist yet (fresh archive, or one just reset via - ``ops reset --index``), not silently return an empty selection -- a fresh - index has nothing indexed, so ``--only-missing`` must select the full - source set, matching ``all_index_rebuild_raw_ids``. -- ``validate_rebuild_index_request`` (the shared service's own validation, - not merely the CLI's) rejects ``max_blob_mb`` without ``raw_ids``/ - ``only_missing``, and rejects a partial selection asking to promote. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path - -import pytest - -from polylogue.maintenance.archive_verification import archive_verification_names_for_route -from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - all_index_rebuild_raw_ids, - missing_index_raw_ids, - validate_rebuild_index_request, -) -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - - -def _seed_source_raw_session(source_db: Path, *, raw_id: str, native_id: str, acquired_at_ms: int) -> None: - with sqlite3.connect(source_db) as conn: - conn.execute("PRAGMA foreign_keys = ON") - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, blob_hash, blob_size, acquired_at_ms - ) - VALUES (?, 'codex-session', ?, ?, zeroblob(32), 0, ?) - """, - (raw_id, native_id, f"/tmp/{native_id}.jsonl", acquired_at_ms), - ) - - -class TestMissingIndexRawIds: - def test_returns_every_raw_id_when_index_tier_does_not_exist_yet(self, tmp_path: Path) -> None: - """polylogue-ogn1 finding #6: a fresh/lost index has nothing indexed. - - Before this fix, ``missing_index_raw_ids`` short-circuited to ``[]`` - whenever ``index.db`` was absent -- which made ``--only-missing`` - rebuild nothing on a fresh archive or right after - ``ops reset --index``, exactly the case it exists to handle. - """ - source_db = tmp_path / "source.db" - initialize_archive_database(source_db, ArchiveTier.SOURCE) - _seed_source_raw_session(source_db, raw_id="raw-1", native_id="n1", acquired_at_ms=1000) - _seed_source_raw_session(source_db, raw_id="raw-2", native_id="n2", acquired_at_ms=2000) - - assert not (tmp_path / "index.db").exists() - assert missing_index_raw_ids(tmp_path) == all_index_rebuild_raw_ids(tmp_path) == ["raw-1", "raw-2"] - - def test_returns_empty_when_source_tier_does_not_exist(self, tmp_path: Path) -> None: - assert missing_index_raw_ids(tmp_path) == [] - - def test_excludes_raw_ids_already_materialized_in_an_existing_index(self, tmp_path: Path) -> None: - source_db = tmp_path / "source.db" - index_db = tmp_path / "index.db" - initialize_archive_database(source_db, ArchiveTier.SOURCE) - initialize_archive_database(index_db, ArchiveTier.INDEX) - _seed_source_raw_session(source_db, raw_id="raw-1", native_id="n1", acquired_at_ms=1000) - _seed_source_raw_session(source_db, raw_id="raw-2", native_id="n2", acquired_at_ms=2000) - with sqlite3.connect(index_db) as conn: - conn.execute("PRAGMA foreign_keys = ON") - conn.execute( - """ - INSERT INTO sessions ( - native_id, origin, raw_id, title, content_hash, created_at_ms, updated_at_ms - ) - VALUES ('n1', 'codex-session', 'raw-1', 'Session n1', zeroblob(32), 1000, 1000) - """ - ) - - # index.db exists and already has raw-1 materialized -- only raw-2 is missing. - assert missing_index_raw_ids(tmp_path) == ["raw-2"] - - -class TestValidateRebuildIndexRequestSharedService: - """polylogue-ogn1 findings #1/#7: enforced by the shared service, not just the CLI.""" - - def test_rejects_max_blob_mb_without_raw_ids_or_only_missing(self, tmp_path: Path) -> None: - request = RebuildIndexRequest(archive_root=tmp_path, max_blob_mb=10.0) - with pytest.raises(ValueError, match="--max-blob-mb requires --only-missing or --raw-id"): - validate_rebuild_index_request(request) - - def test_accepts_max_blob_mb_with_only_missing(self, tmp_path: Path) -> None: - request = RebuildIndexRequest(archive_root=tmp_path, only_missing=True, max_blob_mb=10.0, promote=False) - validate_rebuild_index_request(request) # must not raise - - def test_rejects_partial_selection_that_still_asks_to_promote(self, tmp_path: Path) -> None: - request = RebuildIndexRequest(archive_root=tmp_path, raw_ids=("raw-1",), promote=True) - with pytest.raises(ValueError, match="require --no-promote"): - validate_rebuild_index_request(request) - - def test_rejects_caller_supplied_acceptance_profile_for_promotion(self, tmp_path: Path) -> None: - request = RebuildIndexRequest( - archive_root=tmp_path, - promote=True, - candidate_acceptance_checks=archive_verification_names_for_route("reindex-canary-candidate"), - ) - with pytest.raises(ValueError, match="require --no-promote"): - validate_rebuild_index_request(request) - - def test_allows_caller_supplied_canary_profile_without_promotion(self, tmp_path: Path) -> None: - request = RebuildIndexRequest( - archive_root=tmp_path, - promote=False, - candidate_acceptance_checks=archive_verification_names_for_route("reindex-canary-candidate"), - ) - validate_rebuild_index_request(request) - - def test_candidate_build_is_capability_negative(self, tmp_path: Path) -> None: - from polylogue.operations import CandidateBuildRequest, SourceSeal - - request = RebuildIndexRequest( - archive_root=tmp_path, - candidate_operation=CandidateBuildRequest( - source_seal=SourceSeal( - archive_identity="archive", - source_identity="source", - source_snapshot="snapshot", - source_schema_version=1, - cut_identity="0" * 64, - candidate_manifest_digest="1" * 64, - carry_forward_manifest_digest="2" * 64, - ), - package="package", - code="code", - schemas=("schema",), - parser_declarations=("parser",), - lowering_declarations=("lowering",), - origin_declarations=("origin",), - recipe_version="recipe", - semantic_version="semantic", - ), - promote=False, - ) - validate_rebuild_index_request(request) - - def test_candidate_build_cannot_claim_promotion(self, tmp_path: Path) -> None: - from polylogue.operations import CandidateBuildRequest, SourceSeal - - candidate = CandidateBuildRequest( - source_seal=SourceSeal( - archive_identity="archive", - source_identity="source", - source_snapshot="snapshot", - source_schema_version=1, - cut_identity="0" * 64, - candidate_manifest_digest="1" * 64, - carry_forward_manifest_digest="2" * 64, - ), - package="package", - code="code", - schemas=("schema",), - parser_declarations=("parser",), - lowering_declarations=("lowering",), - origin_declarations=("origin",), - recipe_version="recipe", - semantic_version="semantic", - ) - request = RebuildIndexRequest(archive_root=tmp_path, candidate_operation=candidate, promote=True) - with pytest.raises(ValueError, match="candidate builds require --no-promote"): - validate_rebuild_index_request(request) diff --git a/tests/unit/maintenance/test_rebuild_parse_apply_split.py b/tests/unit/maintenance/test_rebuild_parse_apply_split.py deleted file mode 100644 index 3af5a87f4e..0000000000 --- a/tests/unit/maintenance/test_rebuild_parse_apply_split.py +++ /dev/null @@ -1,296 +0,0 @@ -"""polylogue-623q: the rebuild's parse-vs-apply split, end to end. - -``backfill_historical_revision_evidence`` (``polylogue.sources. -revision_backfill``) has always computed a rich per-stage timing breakdown --- ``census``/``spill_load`` (decode, read-only, parallel) and -``revision_replay.*``/``membership_replay.*`` (writer-side index/FTS/ -projection writes, serialized through the single SQLite writer) -- and -logged it as ``"backfill stage timings: ..."``. Before this change it was -discarded at the function's return boundary: ``RevisionBackfillResult`` had -no field for it, so ``polylogue.maintenance.replay.rebuild_index_from_source`` -(the SAME function ``polylogue.maintenance.rebuild_index. -rebuild_index_from_source_sync`` calls -- the real offline-rebuild and -daemon-bulk-rebuild engine, not a reimplementation) had nothing to thread -into ``RebuildPassCost``, whose ``replay_s`` field covered parse and apply -as one opaque number. - -This module proves the split survives the full real route: -``rebuild_index_from_source_sync`` -> ``rebuild_index_from_source`` (replay -module) -> ``backfill_historical_revision_evidence`` -> a real -``ArchiveStore`` writing real index rows. No mocks. Reverting either the -``RevisionBackfillResult.stage_timings_s`` field or ``replay.py``'s -``parse_s``/``apply_s`` computation makes -``test_rebuild_records_parse_apply_split_summing_to_stage_total`` fail with -a ``KeyError``/``None`` lookup, not merely a wrong number -- the two -components are not independently fabricatable from this test's assertions. -""" - -from __future__ import annotations - -import time -from pathlib import Path - -import pytest - -from devtools.production_reachability import ProductionSeamSpec, check_production_seam -from polylogue.config import Config -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources import revision_backfill -from polylogue.sources.census_parse_stage import CensusParseStage -from polylogue.sources.revision_backfill import split_parse_and_apply_seconds -from tests.infra.rebuild_receipt import write_current_rebuild_receipt -from tests.infra.revision_backfill_benchmark import build_independent_raw_corpus - -REINDEX_PRODUCTION_SEAMS = ( - ProductionSeamSpec( - test_path="tests/unit/maintenance/test_rebuild_parse_apply_split.py", - test_function="test_rebuild_records_parse_apply_split_summing_to_stage_total", - production_entrypoint="polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", - tested_symbols=("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync",), - required_symbols=( - "polylogue.maintenance.replay.rebuild_index_from_source", - "polylogue.storage.repair.repair_session_insights", - ), - ), - ProductionSeamSpec( - test_path="tests/unit/maintenance/test_rebuild_parse_apply_split.py", - test_function="test_rebuild_index_from_source_sync_warms_prefetch_cache_when_caller_omits_one", - production_entrypoint="polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", - tested_symbols=("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync",), - required_symbols=( - "polylogue.maintenance.replay.rebuild_index_from_source", - "polylogue.storage.repair.repair_session_insights", - ), - ), - ProductionSeamSpec( - test_path="tests/unit/maintenance/test_rebuild_parse_apply_split.py", - test_function="test_rebuild_index_from_source_sync_auto_engages_pipelined_decode", - production_entrypoint="polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", - tested_symbols=("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync",), - required_symbols=( - "polylogue.maintenance.replay.rebuild_index_from_source", - "polylogue.storage.repair.repair_session_insights", - ), - ), -) - - -@pytest.mark.load_sensitive -def test_selected_reindex_proof_tests_are_production_reachable() -> None: - """The selected reindex proofs bind to replay and terminal convergence.""" - root = Path(__file__).resolve().parents[3] - for spec in REINDEX_PRODUCTION_SEAMS: - report = check_production_seam(spec, source_root=root) - assert report.ok, report.to_json() - - -def test_split_parse_and_apply_seconds_sums_to_total() -> None: - """Pure rollup: parse is census+spill_load, apply is everything else.""" - stage_timings_s = { - "census": 2.0, - "spill_load": 0.5, - "census_receipt": 0.1, - "revision_replay.index_parsed_write": 1.5, - "membership_replay.index_parsed_write": 0.4, - "total": 4.5, - } - parse_s, apply_s = split_parse_and_apply_seconds(stage_timings_s) - assert parse_s == 2.5 # census + spill_load only - assert apply_s == 2.0 # total - parse_s (census_receipt + writer stages) - assert parse_s + apply_s == stage_timings_s["total"] - - -def test_split_parse_and_apply_seconds_floors_at_zero_when_total_absent() -> None: - """An empty/early-returned timings dict (e.g. zero raws replayed) must - not report a negative or fabricated apply cost.""" - parse_s, apply_s = split_parse_and_apply_seconds({}) - assert (parse_s, apply_s) == (0.0, 0.0) - - -def test_rebuild_records_parse_apply_split_summing_to_stage_total( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Real production route: rebuild_index_from_source_sync must surface a - parse-vs-apply split that sums to the SAME 'total' stage timing that was - already being logged -- not a second, independently-computed number. - """ - root = tmp_path / "archive" - build_independent_raw_corpus(root, raw_count=8, avg_payload_bytes=20_000, authoritative_source=True) - # ArchiveStore.open_owned_inactive_generation resolves the generation - # store via the CONFIGURED archive root (polylogue.paths.archive_root), - # not the archive_root argument threaded through the call -- it must - # point at this test's own root (see - # tests/unit/storage/test_rebuild_paging_content_order.py for the same - # requirement on the same code path). - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - schema_receipt = write_current_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - raw_batch_size=500, # single page: whole corpus fits in one pass - schema_inference_receipt_path=schema_receipt, - ) - ) - - assert receipt.status == "replayed" - replay = receipt.replay - assert "parse_s" in replay - assert "apply_s" in replay - stage_timings_s = replay["stage_timings_s"] - assert isinstance(stage_timings_s, dict) - # Real production stage names must be present -- proof this came from the - # actual backfill, not a stand-in. - assert "census" in stage_timings_s - assert "total" in stage_timings_s - - parse_s = replay["parse_s"] - apply_s = replay["apply_s"] - assert isinstance(parse_s, float) - assert isinstance(apply_s, float) - assert parse_s >= 0.0 - assert apply_s >= 0.0 - # Each of the three numbers is independently rounded to 6 decimals in - # the return dict, so allow a tiny tolerance rather than exact equality. - assert parse_s + apply_s == pytest.approx(stage_timings_s["total"], abs=1e-5) - # This corpus is genuinely replayed (not empty), so real writer work - # happened: apply_s must be strictly positive, not merely non-negative. - assert apply_s > 0.0 - - -def test_rebuild_index_from_source_sync_warms_prefetch_cache_when_caller_omits_one( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """polylogue-czq2: the offline CLI route must get the SAME off-writer - census prefetch seam ``daemon/bulk_rebuild.py`` has always had, not just - a caller that remembers to construct its own ``CensusParseStage``. - - Anti-vacuity: this drives the real production entry point - (``rebuild_index_from_source_sync``, the exact function the CLI's - ``rebuild-index`` command and ``polylogued``'s own HTTP maintenance route - call) with a request that leaves ``RebuildIndexRequest.prefetch_cache`` - at its default ``None`` -- exactly what those two callers do today. Before - ``_rebuild_index_from_source_owned`` grew its internal - ``_warm_offline_prefetch_cache`` call, ``CensusParseStage.warm_raw_ids`` - was reached by exactly one caller in the whole codebase - (``daemon/bulk_rebuild.py``): deleting the internal warm call this test - exercises makes ``warm_raw_ids`` unreached again from this route and this - assertion fails, proving the spy is wired to production code, not a - self-authorized double. - """ - root = tmp_path / "archive" - raw_ids = build_independent_raw_corpus(root, raw_count=6, avg_payload_bytes=20_000, authoritative_source=True) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - schema_receipt = write_current_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - warmed_raw_id_batches: list[tuple[str, ...]] = [] - real_warm_raw_ids = CensusParseStage.warm_raw_ids - - def _spy_warm_raw_ids(self: CensusParseStage, config: Config, *, raw_ids: list[str], max_payload_bytes: int) -> int: - warmed_raw_id_batches.append(tuple(raw_ids)) - return real_warm_raw_ids(self, config, raw_ids=raw_ids, max_payload_bytes=max_payload_bytes) - - monkeypatch.setattr(CensusParseStage, "warm_raw_ids", _spy_warm_raw_ids) - - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - raw_batch_size=500, # single page: whole corpus fits in one pass - schema_inference_receipt_path=schema_receipt, - ) - ) - - assert receipt.status == "replayed" - assert warmed_raw_id_batches, "offline rebuild_index_from_source_sync never warmed a prefetch cache" - # Every raw the census phase went on to process was offered to the warmer - # first -- the exact set this pass selected, not a subset/superset. - assert set(warmed_raw_id_batches[0]) == set(raw_ids) - - -def _give_replay_spill_prefetcher_a_head_start(monkeypatch: pytest.MonkeyPatch) -> None: - """Deterministic race pin, mirroring ``test_revision_backfill.py``'s - identically-named helper: production makes no ordering promise between - the background decode worker and the writer, so a tiny corpus can let - the writer finish before the worker buffers anything, making - ``spill_prefetch.consumed`` a coin flip. Give the worker a bounded head - start before the writer's own replay loop begins.""" - original_start_phase = revision_backfill._ReplaySpillPrefetcher.start_phase - - def start_phase_with_head_start( - self: revision_backfill._ReplaySpillPrefetcher, - ordered_keys: object, - extra_members: object, - ) -> None: - original_start_phase(self, ordered_keys, extra_members) # type: ignore[arg-type] - worker = self._thread - for _ in range(1000): # bounded ~10s; normally exits in milliseconds - with self._lock: - if len(self._buffer) >= 2: - break - if worker is None or not worker.is_alive(): - break - time.sleep(0.01) - - monkeypatch.setattr(revision_backfill._ReplaySpillPrefetcher, "start_phase", start_phase_with_head_start) - - -def test_rebuild_index_from_source_sync_auto_engages_pipelined_decode( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """polylogue-2cuv: BOTH production rebuild routes -- the offline CLI - (``polylogue ops maintenance rebuild-index``) and the daemon bulk-rebuild - loop (``daemon/bulk_rebuild.py::run_daemon_bulk_rebuild_pass``) -- drive - this exact ``rebuild_index_from_source_sync`` entry point (the daemon via - its own write coordinator, unmodified). Both therefore inherit the SAME - ``pipeline_decode`` auto-engagement inside ``backfill_historical_revision_ - evidence`` (Lever A / PR #3478's ``_ReplaySpillPrefetcher``) with zero - per-route wiring -- there is no separate knob either route could forget - to set. - - Anti-vacuity: this drives the real production entry point with a corpus - sized at/above ``_PIPELINE_DECODE_MIN_COHORTS`` (independent raws, so - every raw is its own logical cohort) and shrinks the spill's RAM cache - tiers to 1 byte so every replay ``for_raw`` misses RAM and must go - through the prefetcher-or-inline decode fork. If pipeline_decode were - hardcoded ``False`` somewhere between ``rebuild_index_from_source_sync`` - and ``backfill_historical_revision_evidence`` (e.g. a parameter dropped - while threading a future kwarg), ``spill_prefetch.consumed`` would never - appear in the stage timings and this assertion would fail -- proving the - auto-engagement path is actually reached from the production route, not - only from the lower-level unit tests that call - ``backfill_historical_revision_evidence`` directly. - """ - monkeypatch.setattr(revision_backfill._ParsedSessionSpill, "_DECODED_CACHE_MIN_TREE_BYTES", 1) - monkeypatch.setattr(revision_backfill._ParsedSessionSpill, "_DECODED_CACHE_MAX_TREE_BYTES", 1) - monkeypatch.setattr(revision_backfill._ParsedSessionSpill, "_WHALE_CACHE_MAX_TREE_BYTES", 1) - _give_replay_spill_prefetcher_a_head_start(monkeypatch) - - root = tmp_path / "archive" - cohort_count = revision_backfill._PIPELINE_DECODE_MIN_COHORTS + 4 - raw_ids = build_independent_raw_corpus( - root, raw_count=cohort_count, avg_payload_bytes=20_000, authoritative_source=True - ) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - schema_receipt = write_current_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - raw_batch_size=500, # single page: whole corpus fits in one pass - schema_inference_receipt_path=schema_receipt, - ) - ) - - assert receipt.status == "replayed" - stage_timings_s = receipt.replay["stage_timings_s"] - assert isinstance(stage_timings_s, dict) - assert stage_timings_s.get("spill_prefetch.consumed", 0.0) > 0, ( - "rebuild_index_from_source_sync did not auto-engage the background " - "replay-spill prefetcher (Lever A) for a cohort count above " - "_PIPELINE_DECODE_MIN_COHORTS -- census+spill_load decode is no " - "longer proven to overlap the writer's apply work on this route" - ) - assert receipt.selected_raw_count == len(raw_ids) diff --git a/tests/unit/maintenance/test_rebuild_status.py b/tests/unit/maintenance/test_rebuild_status.py deleted file mode 100644 index 57a76dc6d0..0000000000 --- a/tests/unit/maintenance/test_rebuild_status.py +++ /dev/null @@ -1,210 +0,0 @@ -"""``rebuild_status`` (polylogue-b5l.1 AC5): one consolidated read for lease -ownership, the active generation, the resumable transaction's cursor/delta, -and explicit stale-lock/failed-transaction recovery guidance. - -Anti-vacuity: the mutation that makes -``test_reports_stale_lease_recovery_guidance`` fail is removing the -``lease.stale`` branch's recovery message (or ``rebuild_lease_status``'s own -dead-pid detection this depends on); the mutation that makes -``test_reports_source_snapshot_delta_when_source_has_drifted`` fail is -dropping the ``rebuild_source_evidence_snapshot`` comparison and always -reporting ``source_snapshot_matches=True``. -""" - -from __future__ import annotations - -import fcntl -import json -import os -from pathlib import Path - -import pytest - -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind -from polylogue.core.enums import Provider -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync, rebuild_status -from polylogue.storage.index_generation import IndexGenerationStore, rebuild_source_evidence_snapshot -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database -from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS -from tests.infra.rebuild_receipt import write_current_rebuild_receipt - -_DEFINITELY_DEAD_PID = 2**31 - 1 - - -def _init_empty_source(root: Path) -> None: - root.mkdir(parents=True, exist_ok=True) - for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): - initialize_archive_database(root / f"{tier.value}.db", tier) - - -def _codex_session(native_id: str) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-16T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": f"hello {native_id}"}], - }, - }, - ] - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_one_real_codex_session(root: Path) -> str: - initialize_active_archive_root(root) - with ArchiveStore.open_existing(root, read_only=False) as archive: - native_id = "sess-status-probe" - raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=_codex_session(native_id), - source_path="status-probe-test/0.jsonl", - acquired_at_ms=1, - native_id=native_id, - ) - archive.bind_raw_revision( - raw_id, - RawRevisionEnvelope( - logical_source_key=f"codex-session:{native_id}", - kind=RawRevisionKind.FULL, - source_revision="status-probe:0", - acquisition_generation=0, - baseline_raw_id=raw_id, - authority=RawRevisionAuthority.BYTE_PROVEN, - ), - ) - return raw_id - - -def test_reports_no_lease_and_no_transaction_on_a_fresh_archive(tmp_path: Path) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - - status = rebuild_status(root, operation_id="does-not-exist", include_daemon_bulk_rebuild=False) - - assert status["archive_root"] == str(root) - assert status["lease"] == { - "held": False, - "holder_pid": None, - "holder_host": None, - "holder_alive": None, - "stale": False, - } - assert status["generation"] is None - assert status["transaction"] is None - assert status["delta"] is None - assert status["recovery"] == [] - - -def test_reports_stale_lease_recovery_guidance(tmp_path: Path) -> None: - root = tmp_path / "archive" - _init_empty_source(root) - lock_path = root / ".index-rebuild.lock" - holder_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) - fcntl.flock(holder_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - os.write(holder_fd, f"pid={_DEFINITELY_DEAD_PID} host=nowhere\n".encode()) - os.fsync(holder_fd) - try: - status = rebuild_status(root, operation_id="none", include_daemon_bulk_rebuild=False) - lease = status["lease"] - assert isinstance(lease, dict) - assert lease["held"] is True - assert lease["stale"] is True - recovery = status["recovery"] - assert isinstance(recovery, list) - assert any("dead pid" in message for message in recovery) - assert any("kernel lock is still authoritative" in message for message in recovery) - assert all("reclaims it automatically" not in message for message in recovery) - finally: - fcntl.flock(holder_fd, fcntl.LOCK_UN) - os.close(holder_fd) - - -def test_reports_active_generation_and_schema_version_after_a_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - _seed_one_real_codex_session(root) - schema_receipt = write_current_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, schema_inference_receipt_path=schema_receipt) - ) - assert receipt.status == "replayed" - - status = rebuild_status(root, operation_id="none", include_daemon_bulk_rebuild=False) - - generation = status["generation"] - assert isinstance(generation, dict) - assert generation["state"] == "active" - assert status["schema_version"] is not None - - -def test_reports_transaction_cursor_and_no_delta_when_source_unchanged(tmp_path: Path) -> None: - root = tmp_path / "archive" - raw_id = _seed_one_real_codex_session(root) - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), operation_id="status-probe-op" - ) - transaction = store.checkpoint_transaction( - transaction, status="paused", last_raw_id=raw_id, last_blob_hash_hex="00" * 32, processed_raw_count=1 - ) - - status = rebuild_status(root, operation_id="status-probe-op") - - txn_payload = status["transaction"] - assert isinstance(txn_payload, dict) - assert txn_payload["operation_id"] == "status-probe-op" - assert txn_payload["processed_raw_count"] == 1 - assert txn_payload["heartbeat_at_ms"] is not None - delta = status["delta"] - assert isinstance(delta, dict) - assert delta["source_snapshot_matches"] is True - operation = status["operation"] - assert isinstance(operation, dict) - assert operation["cursor"] is not None - assert operation["heartbeat"] == {"at_ms": txn_payload["heartbeat_at_ms"]} - assert operation["recovery_state"] == "paused" - assert status["recovery"] == [] - - -def test_reports_source_snapshot_delta_when_source_has_drifted(tmp_path: Path) -> None: - root = tmp_path / "archive" - _seed_one_real_codex_session(root) - store = IndexGenerationStore.for_archive_root(root) - transaction = store.create_transaction(source_snapshot="stale-snapshot", operation_id="drift-op") - assert transaction.status == "running" - - status = rebuild_status(root, operation_id="drift-op") - - delta = status["delta"] - assert isinstance(delta, dict) - assert delta["source_snapshot_matches"] is False - recovery = status["recovery"] - assert isinstance(recovery, list) - assert any("source snapshot no longer matches" in message for message in recovery) - - -def test_falls_back_to_the_daemon_well_known_operation_id_by_default(tmp_path: Path) -> None: - """Omitting ``operation_id`` must resolve the daemon's own well-known - bulk-rebuild transaction -- the common case for - ``ops reset --index && polylogued run``, which never has an operation id - to hand this status surface explicitly.""" - from polylogue.daemon.bulk_rebuild import ( - DAEMON_BULK_REBUILD_OPERATION_ID, - resolve_or_start_daemon_bulk_rebuild_transaction, - ) - - root = tmp_path / "archive" - _seed_one_real_codex_session(root) - receipt = write_current_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - resolve_or_start_daemon_bulk_rebuild_transaction(root, schema_inference_receipt_path=receipt) - - status = rebuild_status(root) - - assert status["operation_id"] == DAEMON_BULK_REBUILD_OPERATION_ID - assert status["transaction"] is not None diff --git a/tests/unit/maintenance/test_reindex_campaign.py b/tests/unit/maintenance/test_reindex_campaign.py deleted file mode 100644 index 6dd7c679ee..0000000000 --- a/tests/unit/maintenance/test_reindex_campaign.py +++ /dev/null @@ -1,494 +0,0 @@ -"""Executable reindex campaign corpus and convergence differential tests. - -These tests intentionally call the production ingest, daemon convergence, -inactive rebuild, canary, and debt-retry routes. No test-local parser, -rebuild, promotion, or comparison implementation is used. -""" - -from __future__ import annotations - -import hashlib -import os -import shutil -import sqlite3 -import subprocess -import sys -import tempfile -import threading -import time -from pathlib import Path -from unittest.mock import patch - -import pytest - -from polylogue.daemon.convergence import DaemonConverger -from polylogue.daemon.convergence_stages import _HOT_INSIGHT_SOURCE_BYTES, make_fts_stage, make_insights_stage -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.maintenance.reindex_canary import run_reindex_canary -from polylogue.sources.live.convergence_debt import convergence_debt_from_states -from polylogue.sources.live.cursor import CursorStore -from polylogue.storage.index_generation import IndexGenerationStore -from polylogue.storage.raw_byte_duplicate_supersession import plan_byte_duplicate_supersession -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION -from polylogue.version import POLYLOGUE_VERSION -from tests.infra.archive_templates import clone_archive_template, finalize_archive_template -from tests.infra.convergence_harness import ( - debt_ledger_row, - make_messages_fts_stale, - set_debt_retry_at, -) -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.reindex_campaign import ( - REINDEX_CAMPAIGN_REQUIRED_ORIGINS, - ReindexCampaignCorpus, - build_reindex_campaign_corpus, -) -from tests.infra.reindex_differential import ( - DerivedModelSnapshot, - assert_derived_model_ready, - assert_derived_models_equivalent, - snapshot_derived_model, -) -from tests.infra.source_builders import SyntheticAntigravityLanguageServerClient - -# The module-scoped `reindex_campaign_template` fixture below is local to -# each xdist worker. Under `--dist=loadgroup` (the full-suite route), -# scheduling this module's tests across multiple workers would build the -# heavyweight production-shaped campaign once per worker -- exactly the -# concurrent duplicate build this module exists to avoid. Pin every test in -# this module to one worker. -pytestmark = pytest.mark.xdist_group(name="reindex-campaign") - - -def _digest(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _source_path_for_session(corpus: ReindexCampaignCorpus, session_id: str) -> Path: - with sqlite3.connect(corpus.root / "index.db") as index_conn: - raw_id = str( - index_conn.execute("SELECT raw_id FROM sessions WHERE session_id = ?", (session_id,)).fetchone()[0] - ) - with sqlite3.connect(corpus.root / "source.db") as source_conn: - return Path( - str(source_conn.execute("SELECT source_path FROM raw_sessions WHERE raw_id = ?", (raw_id,)).fetchone()[0]) - ) - - -def _retry_debt_in_fresh_process(index_db: Path) -> None: - repo_root = Path(__file__).resolve().parents[3] - env = os.environ.copy() - existing_pythonpath = env.get("PYTHONPATH") - env["PYTHONPATH"] = str(repo_root) if not existing_pythonpath else f"{repo_root}{os.pathsep}{existing_pythonpath}" - script = ( - "from pathlib import Path\n" - "from polylogue.daemon.cli import _drain_convergence_debt_once\n" - f"result = _drain_convergence_debt_once(Path({str(index_db)!r}))\n" - "assert result >= 1, result\n" - ) - completed = subprocess.run( - [sys.executable, "-c", script], - cwd=repo_root, - env=env, - capture_output=True, - text=True, - timeout=60, - check=False, - ) - assert completed.returncode == 0, f"fresh convergence retry failed:\n{completed.stdout}\n{completed.stderr}" - - -def _snapshot(corpus: ReindexCampaignCorpus) -> DerivedModelSnapshot: - return snapshot_derived_model( - corpus.root, - corpus.root / "index.db", - session_ids=corpus.manifest.session_ids, - search_queries=corpus.manifest.fts_queries, - ) - - -def _clone_campaign_corpus(source: ReindexCampaignCorpus, target_root: Path) -> ReindexCampaignCorpus: - """Clone one converged archive so differential rows retain source identity.""" - - clone_archive_template(source.root, target_root, reject_links=True) - with sqlite3.connect(target_root / "source.db") as conn: - conn.execute( - """ - UPDATE raw_sessions - SET source_path = replace(source_path, ?, ?) - WHERE source_path LIKE ? - """, - (str(source.root), str(target_root), f"{source.root}%"), - ) - conn.commit() - return ReindexCampaignCorpus(root=target_root, manifest=source.manifest) - - -@pytest.fixture(scope="module") -def reindex_campaign_template(tmp_path_factory: pytest.TempPathFactory) -> ReindexCampaignCorpus: - """Build and seal the production-shaped campaign once per worker.""" - corpus = build_reindex_campaign_corpus(tmp_path_factory.mktemp("reindex-campaign-template") / "archive") - finalize_archive_template(corpus.root) - return corpus - - -@pytest.fixture -def reindex_campaign_corpus(reindex_campaign_template: ReindexCampaignCorpus, tmp_path: Path) -> ReindexCampaignCorpus: - """Give each mutating test a cheap, detached writable campaign clone.""" - return _clone_campaign_corpus(reindex_campaign_template, tmp_path / "campaign") - - -def test_reindex_campaign_manifest_has_positive_denominators( - reindex_campaign_template: ReindexCampaignCorpus, -) -> None: - """Every campaign edge class has a real production-ingested witness.""" - - corpus = reindex_campaign_template - corpus.manifest.assert_positive() - assert { - origin for origin, count in corpus.manifest.origin_session_counts if count > 0 - } == REINDEX_CAMPAIGN_REQUIRED_ORIGINS - assert corpus.manifest.lineage_session_ids - assert corpus.manifest.attachment_session_ids - assert corpus.manifest.parser_failure_raw_ids - assert corpus.manifest.duplicate_raw_ids - assert corpus.manifest.restart_session_ids - assert set(corpus.manifest.parser_failure_raw_ids).isdisjoint(corpus.manifest.duplicate_raw_ids) - assert dict(corpus.manifest.fixture_dimensions)["fixture_id"] == "codex-whale-bounds-v2" - assert dict(corpus.manifest.fixture_dimensions)["revision_count"] == 804 - - -@pytest.mark.uses_real_clock("the real UDS daemon readiness probe has a bounded monotonic deadline") -def test_real_inactive_rebuild_and_canary_preserve_active_and_reject_parser_as_duplicate( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - reindex_campaign_corpus: ReindexCampaignCorpus, -) -> None: - """Full replay and the canary use inactive generations and never promote. - - Mutations killed by this test include promoting a no-promote candidate, - omitting structured tool outcomes or attachments during replay, allowing - a parser-failed raw into duplicate-byte supersession, and comparing only - a fabricated summary instead of the candidate's real read model. - """ - - corpus = reindex_campaign_corpus - root = corpus.root - schema_inference_receipt = write_valid_rebuild_receipt( - root, - tmp_path / "schema-inference-gate-receipt.json", - ) - with patch( - "polylogue.sources.parsers.antigravity.AntigravityLanguageServerClient", - SyntheticAntigravityLanguageServerClient, - ): - baseline = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - schema_inference_receipt_path=schema_inference_receipt, - ) - ) - assert baseline.status == "replayed" - active_before = _digest(root / "index.db") - - with patch( - "polylogue.sources.parsers.antigravity.AntigravityLanguageServerClient", - SyntheticAntigravityLanguageServerClient, - ): - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=False, - schema_inference_receipt_path=schema_inference_receipt, - ) - ) - assert receipt.status == "replayed" - assert receipt.generation["state"] == "inactive" - assert receipt.generation["index_path"] != str((root / "index.db").resolve()) - replayed_count = receipt.replay["replayed_logical_source_count"] - assert isinstance(replayed_count, int) and replayed_count >= 1 - - store = IndexGenerationStore.for_archive_root(root) - candidate = Path(str(receipt.generation["index_path"])) - assert store.active_pointer.resolve(strict=True) == (root / "index.db").resolve() - assert _digest(root / "index.db") == active_before - assert candidate.is_file() - - with sqlite3.connect(root / "source.db") as source_conn: - for raw_id in corpus.manifest.parser_failure_raw_ids: - residual = source_conn.execute( - "SELECT parsed_at_ms, parse_error FROM raw_sessions WHERE raw_id = ?", (raw_id,) - ).fetchone() - assert residual is not None and residual[0] is None and residual[1] - assert ( - source_conn.execute( - "SELECT 1 FROM raw_byte_duplicate_supersession_receipts WHERE raw_id = ?", (raw_id,) - ).fetchone() - is None - ) - source_conn = sqlite3.connect(f"file:{root / 'source.db'}?mode=ro", uri=True) - index_conn = sqlite3.connect(f"file:{root / 'index.db'}?mode=ro", uri=True) - try: - duplicate_plan = plan_byte_duplicate_supersession(source_conn, index_conn) - finally: - source_conn.close() - index_conn.close() - assert not ( - {candidate.raw_id for candidate in duplicate_plan.duplicates} & set(corpus.manifest.parser_failure_raw_ids) - ) - - # polylogue-tjr4z: give the canary a genuine, declared authority drift to - # find. The comparison below asserts unexpected_count > 0 to prove the - # canary read the candidate's real read model rather than a fabricated - # summary -- but that only proves anything while the two sides actually - # differ on the revision tables, and a faithful rebuild of this corpus now - # reproduces the active index exactly (measured: identical row counts and - # zero differences across every compared table). - # - # So perturb the ACTIVE index's accepted authority semantically, which is - # precisely the drift class the canary exists to surface. Both columns are - # semantic, not volatile: reindex_canary._VOLATILE_COLUMNS_BY_TABLE ignores - # decided_at_ms/decision_id on these tables specifically so that - # accepted/superseded authority drift stays visible, so a timestamp nudge - # would be correctly ignored and prove nothing. - with sqlite3.connect(root / "index.db") as drift_conn: - drifted_head = drift_conn.execute( - "SELECT logical_source_key, accepted_content_hash FROM raw_revision_heads" - " WHERE session_id IS NOT NULL ORDER BY logical_source_key LIMIT 1" - ).fetchone() - assert drifted_head is not None, "campaign corpus has no session-scoped accepted head to drift" - drift_conn.execute( - "UPDATE raw_revision_heads SET accepted_content_hash = ? WHERE logical_source_key = ?", - (bytes(reversed(bytes(drifted_head[1]))), drifted_head[0]), - ) - drifted_application = drift_conn.execute( - "SELECT decision_id FROM raw_revision_applications" - " WHERE session_id IS NOT NULL AND decision != 'superseded' ORDER BY decision_id LIMIT 1" - ).fetchone() - assert drifted_application is not None, "campaign corpus has no session-scoped application to drift" - drift_conn.execute( - "UPDATE raw_revision_applications SET decision = 'superseded' WHERE decision_id = ?", - (drifted_application[0],), - ) - # Re-anchor the untouched-active digest AFTER the deliberate drift, so the - # assertion after the canary still means "the canary did not write to the - # active index" rather than silently absorbing this edit. - active_before = _digest(root / "index.db") - - # Canary construction is daemon-writer-only. Start the production UDS - # server and its standalone write coordinator against this exact archive; - # patching the client or rebuild function would miss the ownership route - # this campaign is supposed to prove. - runtime_dir = Path(tempfile.mkdtemp(prefix="plg-campaign-uds-")) - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(root)) - monkeypatch.setenv("XDG_RUNTIME_DIR", str(runtime_dir)) - from polylogue.config import load_polylogue_config - from polylogue.daemon.api_auth import resolve_api_auth_token - from polylogue.daemon.http import DaemonAPIHandler - from polylogue.daemon.uds import DaemonAPIUnixHTTPServer, daemon_socket_path - from polylogue.daemon_client import DaemonClient - - daemon_config = load_polylogue_config() - auth_token = resolve_api_auth_token( - daemon_config.api_auth_token, - allow_no_auth=daemon_config.api_allow_no_auth, - token_path=root / "api-auth-token", - ) - socket_path = daemon_socket_path(root, runtime_dir=str(runtime_dir)) - server = DaemonAPIUnixHTTPServer(socket_path, DaemonAPIHandler, auth_token=auth_token) - server_thread = threading.Thread(target=server.serve_forever, name="reindex-campaign-uds", daemon=True) - server_thread.start() - try: - client = DaemonClient(socket_path, timeout_s=1.0, auth_token=auth_token) - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - if ( - client.probe( - archive_root=str(root.resolve()), - index_schema_version=INDEX_SCHEMA_VERSION, - daemon_version=POLYLOGUE_VERSION, - accept_degraded=True, - ) - is not None - ): - break - time.sleep(0.02) - else: - pytest.fail("campaign daemon UDS server did not become ready") - - with patch( - "polylogue.sources.parsers.antigravity.AntigravityLanguageServerClient", - SyntheticAntigravityLanguageServerClient, - ): - canary = run_reindex_canary( - root, - schema_inference_receipt_path=schema_inference_receipt, - sessions_per_origin=100, - no_promote=True, - ) - finally: - server.shutdown() - server.server_close() - server_thread.join(timeout=2) - shutil.rmtree(runtime_dir, ignore_errors=True) - assert canary.comparison.unexpected_count > 0 - assert set(canary.comparison.counts_by_table) == {"raw_revision_applications", "raw_revision_heads"} - canary_generation = canary.rebuild_receipt["generation"] - assert isinstance(canary_generation, dict) and canary_generation["state"] == "inactive" - assert _digest(root / "index.db") == active_before - assert canary.selection.selected_session_ids - - -def test_antigravity_raw_replay_refuses_missing_authoritative_trajectory( - reindex_campaign_corpus: ReindexCampaignCorpus, -) -> None: - """A protobuf raw is replayed through its original language-server source. - - Mutation killed: falling through to generic JSON parsing, which used to - erase the raw membership census and leave a false-clean candidate index. - """ - - corpus = reindex_campaign_corpus - with sqlite3.connect(corpus.root / "source.db") as source_conn: - raw_id, source_path = source_conn.execute( - "SELECT raw_id, source_path FROM raw_sessions WHERE origin = 'antigravity-session'" - ).fetchone() - Path(str(source_path)).unlink() - - with ArchiveStore.open_existing(corpus.root, read_only=False) as archive: - with pytest.raises(RuntimeError, match="requires its original conversations"): - from polylogue.sources.revision_backfill import parse_retained_raw_sessions - - parse_retained_raw_sessions(archive, str(raw_id)) - - -@pytest.mark.uses_real_clock("the restart differential deliberately controls source mtimes around the quiet window") -def test_restart_debt_converges_to_uninterrupted_campaign_state( - tmp_path: Path, reindex_campaign_template: ReindexCampaignCorpus -) -> None: - """A fresh-process debt retry reaches the same state as uninterrupted work.""" - - template = reindex_campaign_template - uninterrupted = _clone_campaign_corpus(template, tmp_path / "uninterrupted") - restarted = _clone_campaign_corpus(template, tmp_path / "restarted") - session_id = uninterrupted.manifest.restart_session_ids[0] - restarted_session_id = restarted.manifest.restart_session_ids[0] - - uninterrupted_removed_fts = make_messages_fts_stale(uninterrupted.root / "index.db", session_id=session_id) - assert uninterrupted_removed_fts > 0 - with sqlite3.connect(uninterrupted.root / "index.db") as conn: - conn.execute("DELETE FROM session_profiles WHERE session_id = ?", (session_id,)) - conn.commit() - uninterrupted_states, _ = DaemonConverger( - (make_fts_stage(uninterrupted.root / "index.db"), make_insights_stage(uninterrupted.root / "index.db")) - ).converge_sessions((session_id,)) - assert uninterrupted_states[session_id].converged - - source_path = _source_path_for_session(restarted, restarted_session_id) - assert source_path.is_file() - restarted_removed_fts = make_messages_fts_stale(restarted.root / "index.db", session_id=restarted_session_id) - assert restarted_removed_fts > 0 - with sqlite3.connect(restarted.root / "index.db") as conn: - assert conn.execute( - "SELECT COUNT(*) FROM messages_fts_identity AS f " - "JOIN blocks AS b ON b.block_id = f.block_id WHERE b.session_id = ?", - (restarted_session_id,), - ).fetchone() == (0,) - with sqlite3.connect(restarted.root / "index.db") as conn: - conn.execute("DELETE FROM session_profiles WHERE session_id = ?", (restarted_session_id,)) - conn.commit() - with source_path.open("ab") as restart_source: - restart_source.truncate(_HOT_INSIGHT_SOURCE_BYTES) - future = time.time() + 100_000 - os.utime(source_path, (future, future)) - stages = (make_fts_stage(restarted.root / "index.db"), make_insights_stage(restarted.root / "index.db")) - deferred_states, _ = DaemonConverger(stages).converge_batch((source_path,)) - assert deferred_states[source_path].converged is False - assert deferred_states[source_path].last_error == "insights deferred until source quiet" - record = CursorStore(restarted.root / "index.db") - record_convergence = convergence_debt_from_states((source_path,), deferred_states) - assert record_convergence - from polylogue.sources.live.convergence_outcome import record_convergence_outcome - - record_convergence_outcome(record, source_path, record_convergence, archive_root=restarted.root) - record.record_convergence_debt( - stage="fts", - subject_type="session_id", - subject_id=restarted_session_id, - error="deliberate restart FTS backlog", - ) - debt = debt_ledger_row( - restarted.root / "ops.db", - stage="insights", - subject_type="session_id", - subject_id=restarted_session_id, - ) - assert debt is not None and debt.status == "deferred" - set_debt_retry_at( - restarted.root / "ops.db", - stage="insights", - subject_type="session_id", - subject_id=restarted_session_id, - retry_at="1970-01-01T00:00:00+00:00", - ) - set_debt_retry_at( - restarted.root / "ops.db", - stage="fts", - subject_type="session_id", - subject_id=restarted_session_id, - retry_at="9999-01-01T00:00:00+00:00", - ) - old = time.time() - 100_000 - os.utime(source_path, (old, old)) - - _retry_debt_in_fresh_process(restarted.root / "index.db") - assert ( - debt_ledger_row( - restarted.root / "ops.db", - stage="insights", - subject_type="session_id", - subject_id=restarted_session_id, - ) - is None - ) - assert ( - debt_ledger_row( - restarted.root / "ops.db", - stage="fts", - subject_type="session_id", - subject_id=restarted_session_id, - ) - is not None - ) - - set_debt_retry_at( - restarted.root / "ops.db", - stage="fts", - subject_type="session_id", - subject_id=restarted_session_id, - retry_at="1970-01-01T00:00:00+00:00", - ) - _retry_debt_in_fresh_process(restarted.root / "index.db") - assert ( - debt_ledger_row( - restarted.root / "ops.db", - stage="fts", - subject_type="session_id", - subject_id=restarted_session_id, - ) - is None - ) - with sqlite3.connect(restarted.root / "index.db") as conn: - assert conn.execute( - "SELECT COUNT(*) FROM messages_fts_identity AS f " - "JOIN blocks AS b ON b.block_id = f.block_id WHERE b.session_id = ?", - (restarted_session_id,), - ).fetchone() == (restarted_removed_fts,) - - uninterrupted_snapshot = _snapshot(uninterrupted) - restarted_snapshot = _snapshot(restarted) - assert_derived_model_ready(uninterrupted_snapshot) - assert_derived_model_ready(restarted_snapshot) - assert_derived_models_equivalent(uninterrupted_snapshot, restarted_snapshot) diff --git a/tests/unit/maintenance/test_reindex_canary.py b/tests/unit/maintenance/test_reindex_canary.py deleted file mode 100644 index 1af9245315..0000000000 --- a/tests/unit/maintenance/test_reindex_canary.py +++ /dev/null @@ -1,2784 +0,0 @@ -"""Focused real-generation tests for the reindex canary differ. - -The production dependency is ``compare_reindex_generations`` reading two -canonical ``index.db`` files. The anti-vacuity mutation for the core test is -changing the candidate block text: a synthetic summary comparator would stay -green, while the real blocks read model must report the changed row. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import shutil -import sqlite3 -import tempfile -from pathlib import Path -from typing import Any, cast - -import pytest - -from polylogue.core.enums import Provider -from polylogue.maintenance import reindex_canary as reindex_canary_module -from polylogue.maintenance.archive_verification import archive_verification_names_for_route -from polylogue.maintenance.rebuild_index import ( - RebuildIndexReceipt, - RebuildIndexRequest, - rebuild_index_from_source_sync, - rebuild_selection_evidence, -) -from polylogue.maintenance.reindex_canary import ( - CanaryDifferenceReview, - CanaryDiffReport, - CanarySelection, - CanarySelectionError, - DeltaExpectation, - DifferenceClassification, - DifferenceOperation, - ExpectedDifference, - RowDifference, - UnclassifiedCanaryDiffError, - compare_reindex_generations, - index_delta_expectations, - load_canary_report, - load_canary_review_manifest, - run_reindex_canary, - select_canary_sessions, - write_canary_report, -) -from polylogue.sources.revision_backfill import ( - RebuildDeadlineExceededError, - backfill_historical_revision_evidence, -) -from polylogue.storage.archive_identity import ArchiveLocation -from polylogue.storage.index_generation import rebuild_source_evidence_snapshot -from polylogue.storage.sqlite import lifecycle as lifecycle_module -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database -from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.lifecycle import ( - CanaryChangeOperation, - DerivedDeltaClass, - ExpectedCanaryChange, - FastForwardOperation, - FastForwardOperationKind, - IndexDeltaDeclaration, - TargetedReprocessScope, - invalid_canary_change_declarations, - undeclared_index_delta_versions, -) -from tests.infra.pathology_zoo import PathologyZoo -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - - -def test_immutable_canary_attestation_failure_does_not_publish_torn_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A partial write leaves no final name, so the attestation can be retried. - - Anti-vacuity: reverting ``_write_immutable_json`` to direct-open publication - makes this test red because the partial final pathname remains. - """ - path = tmp_path / "canary-comparison-attestation.json" - payload: dict[str, object] = {"candidate_generation": {"generation_id": "gen-1-00000000", "owner_id": "owner"}} - real_fsync = os.fsync - - def fail_after_partial_write(descriptor: int) -> None: - raise OSError("simulated fsync failure after write") - - monkeypatch.setattr("polylogue.maintenance.reindex_canary.os.fsync", fail_after_partial_write) - with pytest.raises(OSError, match="simulated fsync failure"): - reindex_canary_module._write_immutable_json(path, payload) - - assert not path.exists() - monkeypatch.setattr("polylogue.maintenance.reindex_canary.os.fsync", real_fsync) - reindex_canary_module._write_immutable_json(path, payload) - assert json.loads(path.read_text(encoding="utf-8")) == payload - - -@pytest.fixture(autouse=True) -def _synthetic_report_provenance(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep structural report tests independent of the CLI lifecycle route. - - The CLI red twins exercise the real archive-owned provenance capture and - reload path. These focused tests construct standalone index pairs solely - to pin comparison and review serialization behavior. - """ - - monkeypatch.setattr( - reindex_canary_module, - "_capture_archive_provenance", - lambda *args, **kwargs: {}, - ) - monkeypatch.setattr( - reindex_canary_module, - "_validate_archive_provenance", - lambda *args, **kwargs: None, - ) - monkeypatch.setattr( - reindex_canary_module, - "_validate_authoritative_rebuild_receipt", - lambda *args, **kwargs: None, - ) - - -def _receipt_path(tmp_path: Path) -> Path: - """A path placeholder for routes whose rebuild call is replaced in-test.""" - path = tmp_path / "schema-inference-gate-receipt.json" - path.touch() - return path - - -def _write_candidate_receipt(archive_root: Path, receipt_path: Path) -> Path: - """Bind a receipt to a fixture that already completed phase 2.""" - return write_valid_rebuild_receipt(archive_root, receipt_path) - - -def _prepare_candidate_ready_archive(root: Path) -> str: - """Build one real-route archive whose source authority is fully settled.""" - initialize_active_archive_root(root) - payload = json.dumps( - { - "chat_messages": [ - {"uuid": "fresh-user", "sender": "human", "text": "hello"}, - { - "uuid": "fresh-assistant", - "sender": "assistant", - "text": "world", - "attachments": [ - { - "id": "fresh-attachment", - "name": "fresh.txt", - "mimeType": "text/plain", - "size": 16, - "extracted_content": "attachment bytes", - } - ], - }, - ] - } - ).encode() - with ArchiveStore.open_existing(root, read_only=False) as archive: - raw_id = archive.write_raw_payload( - provider=Provider.CLAUDE_AI, - payload=payload, - source_path="fresh.json", - native_id="fresh", - acquired_at_ms=1, - ) - backfill_historical_revision_evidence(root) - return raw_id - - -def _seed_index( - path: Path, - *, - sessions: tuple[str, ...] = ("alpha",), - block_text: str = "stable transcript", - profile_materialized_at: str = "first-run", - profile_message_count: int = 1, - origins: tuple[str, ...] | None = None, -) -> None: - initialize_archive_database(path, ArchiveTier.INDEX) - with sqlite3.connect(path) as connection: - session_origins = origins or ("codex-session",) * len(sessions) - assert len(session_origins) == len(sessions) - for native_id, origin in zip(sessions, session_origins, strict=True): - session_id = f"{origin}:{native_id}" - connection.execute( - """ - INSERT INTO sessions(native_id, origin, raw_id, content_hash, message_count) - VALUES (?, ?, ?, ?, 1) - """, - (native_id, origin, f"raw-{native_id}", hashlib.sha256(native_id.encode()).digest()), - ) - connection.execute( - """ - INSERT INTO messages(session_id, position, role, material_origin, content_hash) - VALUES (?, 0, 'user', 'human_authored', ?) - """, - (session_id, hashlib.sha256((native_id + ":message").encode()).digest()), - ) - connection.execute( - """ - INSERT INTO blocks(message_id, session_id, position, block_type, text) - VALUES (?, ?, 0, 'text', ?) - """, - (f"{session_id}:0.0", session_id, block_text), - ) - connection.execute( - """ - INSERT INTO session_profiles(session_id, materialized_at, message_count, tags_json) - VALUES (?, ?, ?, ?) - """, - (session_id, profile_materialized_at, profile_message_count, '{"b":2,"a":1}'), - ) - connection.commit() - - -def _seed_action(path: Path, *, tool_input: str) -> None: - """Create one canonical actions-view row from the real index schema.""" - - session_id = "codex-session:alpha" - message_id = f"{session_id}:0.0" - with sqlite3.connect(path) as connection: - connection.execute( - """ - INSERT INTO blocks(message_id, session_id, position, block_type, tool_id, tool_input) - VALUES (?, ?, 1, 'tool_use', 'tool-alpha', ?) - """, - (message_id, session_id, tool_input), - ) - connection.execute( - """ - INSERT INTO action_pairs( - tool_use_block_id, session_id, message_id, tool_id, use_rank, tool_name - ) VALUES (?, ?, ?, 'tool-alpha', 1, 'shell') - """, - (f"{message_id}:1", session_id, message_id), - ) - connection.commit() - - -def _seed_session_link(path: Path, *, inheritance: str, resolved_parent: bool = False) -> None: - with sqlite3.connect(path) as connection: - connection.execute( - """ - INSERT INTO session_links( - src_session_id, dst_origin, dst_native_id, link_type, - resolved_dst_session_id, inheritance, observed_at_ms - ) VALUES (?, 'codex-session', 'parent', 'resume', ?, ?, 1) - """, - ( - "codex-session:alpha", - "codex-session:parent" if resolved_parent else None, - inheritance, - ), - ) - connection.commit() - - -def _test_selection(index_path: Path) -> CanarySelection: - return CanarySelection( - index_path=index_path, - sessions_per_origin=1, - selected_session_ids=("codex-session:alpha",), - selected_raw_ids=("raw-alpha",), - sampled_session_ids=("codex-session:alpha",), - pathology_session_ids=(), - sample_session_ids=(), - origin_counts=(), - ) - - -def _empty_comparison(current: Path, candidate: Path, session_ids: tuple[str, ...]) -> CanaryDiffReport: - return CanaryDiffReport( - current_index=current, - candidate_index=candidate, - session_ids=session_ids, - compared_tables=(), - missing_tables=(), - missing_columns=(), - differences=(), - ) - - -def _rebuild_receipt(selection: CanarySelection, comparison: CanaryDiffReport) -> dict[str, object]: - from polylogue.maintenance.archive_verification import archive_verification_names_for_route - - canary_checks = archive_verification_names_for_route("reindex-canary-candidate") - - generation = { - "generation_id": "gen-canary", - "owner_id": "owner", - "archive_root": str(comparison.candidate_index.parent), - "index_path": str(comparison.candidate_index), - "state": "inactive", - "source_snapshot": "snapshot", - } - return { - "receipt_schema_version": 5, - "archive_root": str(comparison.candidate_index.parent), - "selected_raw_count": len(selection.selected_raw_ids), - "status": "replayed", - "materialized": True, - "generation": generation, - "selection_evidence": rebuild_selection_evidence( - selection.selected_raw_ids, - archive_root=comparison.candidate_index.parent, - generation_id="gen-canary", - generation_owner_id="owner", - candidate_index=comparison.candidate_index, - source_snapshot="snapshot", - selected_session_ids=selection.selected_session_ids, - ), - "source_evidence_after": "0" * 64, - "canary_acceptance": { - "profile": "reindex-canary-v2-domain-coverage", - "results": [ - {"name": name, "status": "ok", "summary": "fixture acceptance", "count": 0} for name in canary_checks - ], - }, - } - - -def _offline_canary_rebuild( - *, - archive_root: Path, - raw_ids: tuple[str, ...], - selected_session_ids: tuple[str, ...], - index_schema_version: int, - schema_inference_receipt_path: Path, -) -> RebuildIndexReceipt: - """Exercise canary post-rebuild guards with the real rebuild service. - - Daemon transport itself is covered separately against the production UDS - endpoint. These tests mutate production rebuild output after that route. - """ - return rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=archive_root, - raw_ids=raw_ids, - selected_session_ids=selected_session_ids, - promote=False, - canary=True, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - ) - - -def _offline_canary_discard(*, archive_root: Path, generation_id: str, generation_owner_id: str) -> None: - """Exercise daemon-owned discard semantics against the fixture archive.""" - from polylogue.maintenance.rebuild_index import discard_inactive_rebuild_candidate - - discard_inactive_rebuild_candidate(archive_root, generation_id, generation_owner_id) - - -@pytest.fixture(autouse=True) -def _run_canary_postflight_tests_through_real_rebuild_service( - monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest -) -> None: - """Keep post-rebuild guards local; daemon transport has its own route test.""" - if request.node.name != "test_daemon_canary_rebuild_posts_the_bound_canary_request_to_the_existing_daemon_route": - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", _offline_canary_rebuild) - monkeypatch.setattr( - "polylogue.daemon.bulk_rebuild.discard_daemon_canary_candidate", - _offline_canary_discard, - ) - - -def test_equal_real_generations_ignore_only_materialization_metadata(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, profile_materialized_at="current-build") - _seed_index(candidate, profile_materialized_at="candidate-build") - - report = compare_reindex_generations(current, candidate) - - assert report.differences == () - assert report.unclassified_count == 0 - assert {"sessions", "messages", "blocks", "session_profiles"}.issubset(report.compared_tables) - - -def test_differ_reports_real_core_and_derived_row_changes(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, sessions=("alpha", "removed")) - _seed_index( - candidate, - sessions=("alpha", "added"), - block_text="changed transcript", - profile_message_count=2, - ) - - report = compare_reindex_generations(current, candidate) - - assert report.unexpected_count > 0 - assert report.unclassified_count == 0 - operations = {(item.table, item.operation) for item in report.differences} - assert ("blocks", DifferenceOperation.CHANGED) in operations - assert ("blocks", DifferenceOperation.ADDED) in operations - assert ("blocks", DifferenceOperation.REMOVED) in operations - assert any( - item.table == "session_profiles" - and item.operation is DifferenceOperation.CHANGED - and "message_count" in item.changed_columns - for item in report.differences - ) - assert all(item.classification is DifferenceClassification.UNEXPECTED for item in report.differences) - - -def test_missing_tables_and_columns_are_explicit_unexpected_differences(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - with sqlite3.connect(candidate) as connection: - connection.execute("ALTER TABLE session_profiles DROP COLUMN tags_json") - connection.execute("DROP TABLE blocks") - connection.commit() - - report = compare_reindex_generations(current, candidate) - - assert "blocks" in report.missing_tables - assert report.missing_columns == (("session_profiles", ("tags_json",)),) - schema_differences = [item for item in report.differences if item.identity[0][0] == "__schema__"] - assert { - ("blocks", DifferenceOperation.REMOVED), - ("session_profiles", DifferenceOperation.REMOVED), - }.issubset({(item.table, item.operation) for item in schema_differences}) - assert report.unexpected_count == len(report.differences) - - -def test_expected_difference_is_structurally_accounted_for(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate, profile_message_count=2) - - report = compare_reindex_generations( - current, - candidate, - expected=( - ExpectedDifference( - table="session_profiles", - identity=(("session_id", "codex-session:alpha"),), - operations=(DifferenceOperation.CHANGED,), - columns=("message_count",), - bead_ref="polylogue-example", - rationale="the reviewed materializer change updates this aggregate", - ), - ), - ) - - profile_changes = [item for item in report.differences if item.table == "session_profiles"] - assert profile_changes - assert all(item.classification is DifferenceClassification.EXPECTED for item in profile_changes) - assert all("polylogue-example" in item.rationale for item in profile_changes) - assert report.expected_count == len(profile_changes) - - -def test_expected_difference_cannot_hide_extra_changed_columns(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate, profile_message_count=2) - with sqlite3.connect(candidate) as connection: - connection.execute("UPDATE session_profiles SET tags_json = ?", ('{"extra":true}',)) - connection.commit() - - report = compare_reindex_generations( - current, - candidate, - expected=( - ExpectedDifference( - table="session_profiles", - identity=(("session_id", "codex-session:alpha"),), - operations=(DifferenceOperation.CHANGED,), - columns=("message_count",), - bead_ref="polylogue-example", - rationale="the reviewed materializer change updates this aggregate", - ), - ), - ) - - profile_changes = [item for item in report.differences if item.table == "session_profiles"] - assert len(profile_changes) == 1 - assert profile_changes[0].changed_columns == ("tags_json", "message_count") - assert profile_changes[0].classification is DifferenceClassification.UNEXPECTED - - -def test_expected_difference_for_alpha_does_not_waive_beta(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, sessions=("alpha", "beta")) - _seed_index(candidate, sessions=("alpha", "beta"), profile_message_count=2) - report = compare_reindex_generations( - current, - candidate, - expected=( - ExpectedDifference( - table="session_profiles", - identity=(("session_id", "codex-session:alpha"),), - operations=(DifferenceOperation.CHANGED,), - columns=("message_count",), - bead_ref="ref", - rationale="alpha only", - ), - ), - ) - classifications = { - dict(item.identity)["session_id"]: item.classification - for item in report.differences - if item.table == "session_profiles" - } - assert classifications == { - "codex-session:alpha": DifferenceClassification.EXPECTED, - "codex-session:beta": DifferenceClassification.UNEXPECTED, - } - - -def test_expected_difference_requires_exact_operation_and_nonempty_signature() -> None: - """A table-wide declaration cannot classify every future row difference.""" - - with pytest.raises(ValueError, match="exactly one operation"): - ExpectedDifference( - table="session_profiles", - identity=(("session_id", "codex-session:alpha"),), - bead_ref="polylogue-example", - rationale="too broad", - ) - - with pytest.raises(ValueError, match="non-empty changed-column signature"): - ExpectedDifference( - table="session_profiles", - identity=(("session_id", "codex-session:alpha"),), - operations=(DifferenceOperation.CHANGED,), - bead_ref="polylogue-example", - rationale="still too broad", - ) - - -def test_expected_difference_requires_exact_schema_asymmetry_signature(tmp_path: Path) -> None: - """Schema deltas need the same precise operation and column signature.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - with sqlite3.connect(candidate) as connection: - connection.execute("ALTER TABLE session_profiles DROP COLUMN tags_json") - connection.commit() - - report = compare_reindex_generations( - current, - candidate, - expected=( - ExpectedDifference( - table="session_profiles", - identity=(("__schema__", "column"), ("name", "tags_json")), - operations=(DifferenceOperation.REMOVED,), - columns=("message_count",), - bead_ref="polylogue-example", - rationale="wrong schema signature", - ), - ), - ) - - schema_delta = next(item for item in report.differences if item.table == "session_profiles") - assert schema_delta.changed_columns == ("tags_json",) - assert schema_delta.classification is DifferenceClassification.UNEXPECTED - - -def test_differ_compares_canonical_actions_view(tmp_path: Path) -> None: - """Changing the blocks-backed action payload must surface through actions.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - _seed_action(current, tool_input='{"command":"current"}') - _seed_action(candidate, tool_input='{"command":"candidate"}') - - report = compare_reindex_generations(current, candidate) - - action_delta = next(item for item in report.differences if item.table == "actions") - assert action_delta.operation is DifferenceOperation.CHANGED - assert action_delta.identity == (("tool_use_block_id", "codex-session:alpha:0.0:1"),) - assert "tool_input" in action_delta.changed_columns - - -def test_differ_does_not_omit_session_links_from_the_canonical_relation_frame(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - _seed_session_link(current, inheritance="prefix-sharing") - _seed_session_link(candidate, inheritance="spawned-fresh") - - report = compare_reindex_generations(current, candidate) - - link_delta = next(item for item in report.differences if item.table == "session_links") - assert link_delta.operation is DifferenceOperation.CHANGED - assert "inheritance" in link_delta.changed_columns - assert "session_links" in report.compared_tables - - -def test_differ_excludes_child_session_link_when_only_its_destination_is_selected(tmp_path: Path) -> None: - """A selected destination cannot pull an unselected child edge into the canary.""" - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, sessions=("parent", "alpha")) - _seed_index(candidate, sessions=("parent", "alpha")) - _seed_session_link(current, inheritance="prefix-sharing", resolved_parent=True) - _seed_session_link(candidate, inheritance="spawned-fresh", resolved_parent=True) - - report = compare_reindex_generations(current, candidate, session_ids=("codex-session:parent",)) - - assert report.differences == () - - -def test_selected_sessions_bound_the_canary_to_a_real_subset(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, sessions=("kept", "outside")) - _seed_index(candidate, sessions=("kept", "outside")) - with sqlite3.connect(candidate) as connection: - connection.execute( - "UPDATE blocks SET text = 'outside changed' WHERE session_id = ?", - ("codex-session:outside",), - ) - connection.commit() - - report = compare_reindex_generations(current, candidate, session_ids=("codex-session:kept",)) - - assert report.session_ids == ("codex-session:kept",) - assert report.differences == () - - -def test_parser_fingerprint_binding_fails_closed_when_evidence_changes() -> None: - evidence = { - "replay_closure": { - "raw_session_evidence": [ - { - "origin": "codex-session", - "parser_fingerprint": "parser-v1", - "lowering_fingerprint": "lower-v1", - } - ] - } - } - expected = { - ("codex-session", "parser-v1"), - } - reindex_canary_module._validate_parser_binding( - evidence, - expected_parser_fingerprints=expected, - expected_lowering_fingerprint="lower-v1", - ) - changed = json.loads(json.dumps(evidence)) - changed["replay_closure"]["raw_session_evidence"][0]["parser_fingerprint"] = "parser-v2" - - with pytest.raises(UnclassifiedCanaryDiffError, match="parser fingerprints"): - reindex_canary_module._validate_parser_binding( - changed, - expected_parser_fingerprints=expected, - expected_lowering_fingerprint="lower-v1", - ) - - -def test_rebuild_selection_evidence_binds_compared_sessions_and_production_replay_routing(tmp_path: Path) -> None: - """The actual rebuild receipt binds both the replay and comparison denominator.""" - root = tmp_path / "archive" - raw_id = _prepare_candidate_ready_archive(root) - from polylogue.sources.origin_specs import materializer_fingerprint, replay_routing_fingerprint - - evidence = rebuild_selection_evidence( - (raw_id,), - archive_root=root, - generation_id="generation", - generation_owner_id="owner", - candidate_index=root / "index.db", - source_snapshot="snapshot", - selected_session_ids=("claude-ai-export:fresh",), - ) - - assert evidence["selected_session_ids"] == ["claude-ai-export:fresh"] - assert evidence["selected_session_count"] == 1 - closure = cast(dict[str, object], evidence["replay_closure"]) - raw_evidence = cast(list[dict[str, object]], closure["raw_session_evidence"]) - assert raw_evidence[0]["replay_routing_fingerprint"] == replay_routing_fingerprint() - assert raw_evidence[0]["materializer_fingerprint"] == materializer_fingerprint() - - -def test_daemon_canary_rebuild_posts_the_bound_canary_request_to_the_existing_daemon_route( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The canary transport is the real daemon UDS route, not a local writer bridge.""" - from polylogue.daemon import bulk_rebuild - - calls: dict[str, object] = {} - - class Client: - def __init__(self, *args: object, **kwargs: object) -> None: - calls["init"] = (args, kwargs) - - def probe(self, **kwargs: object) -> dict[str, object]: - calls["probe"] = kwargs - return {"ok": True} - - def request_json(self, method: str, path: str, body: dict[str, object], **kwargs: object) -> dict[str, object]: - calls["request"] = (method, path, body, kwargs) - return {"status": "replayed"} - - monkeypatch.setattr("polylogue.daemon_client.DaemonClient", Client) - monkeypatch.setattr("polylogue.daemon.api_auth.resolve_api_auth_token", lambda *args, **kwargs: "token") - - receipt = bulk_rebuild.run_daemon_canary_rebuild( - archive_root=tmp_path, - raw_ids=("raw-1",), - selected_session_ids=("codex-session:one",), - index_schema_version=42, - schema_inference_receipt_path=tmp_path / "schema.json", - ) - - assert receipt == {"status": "replayed"} - assert calls["request"] == ( - "POST", - "/api/maintenance/rebuild-index", - { - "raw_ids": ["raw-1"], - "selected_session_ids": ["codex-session:one"], - "promote": False, - "canary": True, - "schema_inference_receipt_path": str(tmp_path / "schema.json"), - }, - {}, - ) - _socket_path, client_options = cast(tuple[object, dict[str, object]], calls["init"]) - assert client_options["timeout_s"] is None - - -def test_daemon_canary_report_consumption_posts_to_the_writer_owned_route( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Consumption uses the daemon route rather than local archive ownership.""" - from polylogue.daemon import bulk_rebuild - - calls: dict[str, object] = {} - - class Client: - def __init__(self, *args: object, **kwargs: object) -> None: - calls["init"] = (args, kwargs) - - def request_json(self, method: str, path: str, body: dict[str, object], **kwargs: object) -> dict[str, object]: - calls["request"] = (method, path, body, kwargs) - return {"review_status": "reviewed"} - - monkeypatch.setattr("polylogue.daemon_client.DaemonClient", Client) - monkeypatch.setattr("polylogue.daemon.api_auth.resolve_api_auth_token", lambda *args, **kwargs: "token") - - report_path = tmp_path / "report.json" - assert bulk_rebuild.consume_daemon_canary_report(archive_root=tmp_path, report_path=report_path) == { - "review_status": "reviewed" - } - assert calls["request"] == ( - "POST", - "/api/maintenance/consume-canary-report", - {"report_path": str(report_path.resolve())}, - {"raise_for_status": True}, - ) - _socket_path, client_options = cast(tuple[object, dict[str, object]], calls["init"]) - assert client_options["timeout_s"] is None - - -def test_daemon_canary_report_consumption_preserves_typed_validation_detail( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The maintenance adapter retains the daemon's actionable 4xx detail for the CLI.""" - from polylogue.daemon import bulk_rebuild - from polylogue.daemon_client import DaemonResponseError - - class Client: - def __init__(self, *args: object, **kwargs: object) -> None: - pass - - def request_json(self, *args: object, **kwargs: object) -> None: - raise DaemonResponseError( - status=422, - code="canary_report_invalid", - detail="receipt is missing the canonical acceptance profile", - ) - - monkeypatch.setattr("polylogue.daemon_client.DaemonClient", Client) - monkeypatch.setattr("polylogue.daemon.api_auth.resolve_api_auth_token", lambda *args, **kwargs: "token") - - with pytest.raises(UnclassifiedCanaryDiffError, match="missing the canonical acceptance profile"): - bulk_rebuild.consume_daemon_canary_report(archive_root=tmp_path, report_path=tmp_path / "report.json") - - -def test_canary_cleanup_dispatches_dictionary_receipts_to_the_daemon( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """Postflight failures must release a daemon receipt candidate through its owner.""" - captured: dict[str, object] = {} - - def discard(**kwargs: object) -> None: - captured.update(kwargs) - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.discard_daemon_canary_candidate", discard) - - errors = reindex_canary_module._discard_canary_candidate( - tmp_path, - { - "generation": { - "generation_id": "candidate-1", - "owner_id": "owner-1", - } - }, - ) - - assert errors == [] - assert captured == { - "archive_root": tmp_path, - "generation_id": "candidate-1", - "generation_owner_id": "owner-1", - } - - -def test_live_replay_routing_fingerprint_rejects_changed_running_code(monkeypatch: pytest.MonkeyPatch) -> None: - """Persisted routing evidence cannot approve a changed replay dispatcher.""" - monkeypatch.setattr("polylogue.sources.origin_specs.replay_routing_fingerprint", lambda: "changed-routing") - - with pytest.raises(UnclassifiedCanaryDiffError, match="running code"): - reindex_canary_module._validate_live_replay_routing_fingerprint("recorded-routing") - - -def test_live_materializer_fingerprint_rejects_changed_running_code(monkeypatch: pytest.MonkeyPatch) -> None: - """The production guard rejects a materializer code mutation.""" - monkeypatch.setattr("polylogue.sources.origin_specs.materializer_fingerprint", lambda: "changed-materializer") - - with pytest.raises(UnclassifiedCanaryDiffError, match="materializer fingerprint no longer matches"): - reindex_canary_module._validate_live_materializer_fingerprint("recorded-materializer") - - -def test_expected_delta_authority_resolves_outside_a_git_checkout( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """Installed canaries resolve semantic authority from packaged declarations.""" - monkeypatch.chdir(tmp_path) - difference = RowDifference( - table="sessions", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "codex-session:sample"),), - before={"title_ref": None}, - after={"title_ref": "message:codex-session:sample:user"}, - changed_columns=("title_ref",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - delta = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="declared targeted title reprocess", - ) - reindex_canary_module._validate_expected_review_authorities((delta,)) - - -def test_crossed_delta_authority_rejects_reported_source_version_mismatch(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - with sqlite3.connect(current) as connection: - connection.execute("PRAGMA user_version = 43") - connection.commit() - with sqlite3.connect(candidate) as connection: - connection.execute("PRAGMA user_version = 44") - connection.commit() - comparison = compare_reindex_generations( - current, - candidate, - session_ids=("codex-session:sample",), - source_index_version=42, - ) - with pytest.raises(UnclassifiedCanaryDiffError, match="does not match persisted"): - reindex_canary_module._crossed_delta_versions_for_comparison(comparison) - - -def test_expected_delta_authority_requires_crossed_source_versions() -> None: - difference = RowDifference( - table="sessions", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "codex-session:sample"),), - before={"title_ref": None}, - after={"title_ref": "message:codex-session:sample:user"}, - changed_columns=("title_ref",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - review = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="declared targeted title reprocess", - ) - with pytest.raises(UnclassifiedCanaryDiffError, match="not crossed"): - reindex_canary_module._validate_expected_review_authorities( - (review,), crossed_delta_versions=(43,), require_crossed_delta_versions=True - ) - with pytest.raises(UnclassifiedCanaryDiffError, match="source index version evidence"): - reindex_canary_module._validate_expected_review_authorities( - (review,), crossed_delta_versions=None, require_crossed_delta_versions=True - ) - - -def test_expected_delta_authority_rejects_unrelated_table() -> None: - """A historical delta number cannot bless an arbitrary semantic change.""" - - difference = RowDifference( - table="blocks", - operation=DifferenceOperation.CHANGED, - identity=(("block_id", "block"),), - before={"text": "before"}, - after={"text": "after"}, - changed_columns=("text",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - unrelated = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="unrelated packaged index delta", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="does not declare table blocks"): - reindex_canary_module._validate_expected_review_authorities((unrelated,)) - - -def test_expected_delta_authority_rejects_session_outside_declared_origin() -> None: - """A table declaration cannot authorize rows outside its reprocess scope.""" - - difference = RowDifference( - table="sessions", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "chatgpt-export:sample"),), - before={"title_ref": None}, - after={"title_ref": "message:chatgpt-export:sample:user"}, - changed_columns=("title_ref",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - outside_scope = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="unrelated origin", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="outside origin codex-session"): - reindex_canary_module._validate_expected_review_authorities((outside_scope,)) - - -def test_expected_delta_authority_rejects_undeclared_changed_column() -> None: - """A semantic delta authorizes named values, not every column in its table.""" - - difference = RowDifference( - table="sessions", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "codex-session:sample"),), - before={"content_hash": "before"}, - after={"content_hash": "after"}, - changed_columns=("content_hash",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - undeclared_column = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:44", - rationale="unrelated column", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="does not declare changed columns"): - reindex_canary_module._validate_expected_review_authorities((undeclared_column,)) - - -def test_expected_delta_authority_rejects_nonsemantic_delta() -> None: - """A DDL-only delta cannot authorize a changed row value.""" - - difference = RowDifference( - table="insight_materialization", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "session"), ("insight_type", "session_profile")), - before={"materializer_version": 1}, - after={"materializer_version": 2}, - changed_columns=("materializer_version",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - constraint_only = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:33", - rationale="constraint-only delta", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="does not declare a semantic reparse"): - reindex_canary_module._validate_expected_review_authorities((constraint_only,)) - - -def test_expected_delta_authority_rejects_unscoped_semantic_delta() -> None: - """A semantic label without comparable objects cannot bless every table.""" - - difference = RowDifference( - table="session_events", - operation=DifferenceOperation.REMOVED, - identity=(("event_id", "event"),), - before={"event_type": "agent_message"}, - after=None, - changed_columns=("event_type",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - unscoped = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:42", - rationale="unscoped writer-materialization delta", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="does not declare comparable table scope"): - reindex_canary_module._validate_expected_review_authorities((unscoped,)) - - -def test_unknown_expected_delta_authority_fails_closed() -> None: - difference = RowDifference( - table="blocks", - operation=DifferenceOperation.CHANGED, - identity=(("block_id", "block"),), - before={"text": "before"}, - after={"text": "after"}, - changed_columns=("text",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - unknown = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:999999", - rationale="not declared", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="unknown index delta"): - reindex_canary_module._validate_expected_review_authorities((unknown,)) - - -def test_canary_comparison_is_read_only(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - before = current.stat().st_ino, current.stat().st_size, candidate.stat().st_ino, candidate.stat().st_size - - compare_reindex_generations(current, candidate) - - after = current.stat().st_ino, current.stat().st_size, candidate.stat().st_ino, candidate.stat().st_size - assert after == before - - -def test_revision_receipt_run_identity_is_not_a_semantic_canary_difference(tmp_path: Path) -> None: - """Rebuild-local ids/times normalize while revision authority stays compared.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - session_id = "codex-session:alpha" - raw_id = "raw-alpha" - content_hash = hashlib.sha256(b"alpha").digest() - for path, decision_id, decided_at_ms in ((current, "decision-current", 1), (candidate, "decision-candidate", 2)): - with sqlite3.connect(path) as connection: - connection.execute( - """ - INSERT INTO raw_revision_applications( - decision_id, raw_id, session_id, logical_source_key, source_revision, - acquisition_generation, decision, accepted_raw_id, - accepted_source_revision, accepted_content_hash, accepted_frontier_kind, - accepted_frontier, detail, decided_at_ms - ) VALUES (?, ?, ?, 'codex:alpha', '1', 0, 'selected_baseline', ?, '1', ?, 'semantic', 1, 'selected', ?) - """, - (decision_id, raw_id, session_id, raw_id, content_hash, decided_at_ms), - ) - connection.execute( - """ - INSERT INTO raw_revision_heads( - logical_source_key, session_id, accepted_raw_id, accepted_source_revision, - accepted_content_hash, accepted_frontier_kind, accepted_frontier, - acquisition_generation, decided_at_ms - ) VALUES ('codex:alpha', ?, ?, '1', ?, 'semantic', 1, 0, ?) - """, - (session_id, raw_id, content_hash, decided_at_ms), - ) - - report = compare_reindex_generations(current, candidate) - - assert report.differences == () - - -def test_revision_receipt_semantic_decision_remains_a_canary_difference(tmp_path: Path) -> None: - """Normalizing attempt identity must not hide a changed authority decision.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate) - session_id = "codex-session:alpha" - for path, decision in ((current, "selected_baseline"), (candidate, "superseded")): - with sqlite3.connect(path) as connection: - connection.execute( - """ - INSERT INTO raw_revision_applications( - decision_id, raw_id, session_id, logical_source_key, source_revision, - acquisition_generation, decision, accepted_raw_id, - accepted_source_revision, accepted_content_hash, detail, decided_at_ms - ) VALUES (?, 'raw-alpha', ?, 'codex:alpha', '1', 0, ?, NULL, NULL, NULL, 'decision', 1) - """, - (f"decision-{decision}", session_id, decision), - ) - - report = compare_reindex_generations(current, candidate) - - assert {difference.table for difference in report.differences} == {"raw_revision_applications"} - - -def test_selector_samples_each_origin_and_keeps_explicit_inputs(tmp_path: Path) -> None: - index = tmp_path / "index.db" - _seed_index( - index, - sessions=("codex-a", "codex-pathology", "chat-a", "chat-sample", "claude-a", "claude-pathology"), - origins=( - "codex-session", - "codex-session", - "chatgpt-export", - "chatgpt-export", - "claude-ai-export", - "claude-ai-export", - ), - ) - - selection = select_canary_sessions( - index, - sessions_per_origin=1, - pathology_session_ids=("codex-session:codex-pathology", "claude-ai-export:claude-pathology"), - sample_session_ids=("chatgpt-export:chat-sample",), - ) - - assert selection.origin_counts == ( - ("chatgpt-export", 2), - ("claude-ai-export", 2), - ("codex-session", 2), - ) - assert selection.selected_session_ids == ( - "chatgpt-export:chat-a", - "chatgpt-export:chat-sample", - "claude-ai-export:claude-a", - "claude-ai-export:claude-pathology", - "codex-session:codex-a", - "codex-session:codex-pathology", - ) - assert selection.selected_raw_ids == ( - "raw-chat-a", - "raw-chat-sample", - "raw-claude-a", - "raw-claude-pathology", - "raw-codex-a", - "raw-codex-pathology", - ) - - -def test_selector_refuses_unknown_or_non_replayable_explicit_sessions(tmp_path: Path) -> None: - index = tmp_path / "index.db" - _seed_index(index) - with pytest.raises(CanarySelectionError, match="not indexed"): - select_canary_sessions(index, pathology_session_ids=("codex-session:missing",)) - with sqlite3.connect(index) as connection: - connection.execute("UPDATE sessions SET raw_id = NULL") - connection.commit() - with pytest.raises(CanarySelectionError, match="no raw_id"): - select_canary_sessions(index, pathology_session_ids=("codex-session:alpha",)) - - -def test_run_reindex_canary_automatically_includes_production_pathology_sessions( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The real canary runner supplements operator IDs from the production manifest.""" - from polylogue.maintenance.pathology_zoo import pathology_zoo_session_ids - - current = tmp_path / "index.db" - pathology_session_ids = pathology_zoo_session_ids() - native_ids = tuple(session_id.split(":", 1)[1] for session_id in pathology_session_ids) - origins = tuple(session_id.split(":", 1)[0] for session_id in pathology_session_ids) - _seed_index(current, sessions=native_ids, origins=origins) - captured: dict[str, object] = {} - captured_receipt_path: Path | None = None - - class Receipt: - def to_dict(self) -> dict[str, object]: - return {"status": "replayed"} - - def fake_rebuild(**request: object) -> Receipt: - nonlocal captured_receipt_path - captured["raw_ids"] = tuple(cast(tuple[str, ...], request["raw_ids"])) - captured["has_client_profile"] = "candidate_acceptance_checks" in request - captured["promote"] = False - captured_receipt_path = cast(Path, request["schema_inference_receipt_path"]) - return Receipt() - - def fake_compare( - current_index: Path, - candidate_index: Path, - *, - session_ids: tuple[str, ...], - **provenance: object, - ) -> CanaryDiffReport: - captured["session_ids"] = session_ids - captured.update(provenance) - return _empty_comparison(current_index, candidate_index, session_ids) - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", fake_rebuild) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_selection_evidence", lambda *args, **kwargs: None - ) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_authoritative_rebuild_receipt", lambda *args, **kwargs: None - ) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_canary_candidate", lambda *args, **kwargs: current - ) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.compare_reindex_generations", fake_compare) - - receipt_path = _receipt_path(tmp_path) - result = run_reindex_canary( - tmp_path, - input_index=current, - schema_inference_receipt_path=receipt_path, - sessions_per_origin=1, - no_promote=True, - ) - - assert result.selection.pathology_session_ids == pathology_session_ids - captured_session_ids = captured["session_ids"] - captured_raw_ids = captured["raw_ids"] - assert isinstance(captured_session_ids, tuple) - assert isinstance(captured_raw_ids, tuple) - assert set(pathology_session_ids) <= set(captured_session_ids) - assert len(captured_raw_ids) == len(pathology_session_ids) - assert captured["has_client_profile"] is False - assert captured["promote"] is False - assert captured_receipt_path == receipt_path - - -def test_selector_refuses_empty_automatic_selection_before_daemon_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A source row with no indexed session cannot trigger full-source replay.""" - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - with sqlite3.connect(root / "index.db") as connection: - connection.execute("DELETE FROM sessions") - connection.commit() - rebuild_called = False - - def unexpected_rebuild(*args: object, **kwargs: object) -> None: - nonlocal rebuild_called - rebuild_called = True - raise AssertionError("empty canary selection must not reach the rebuild engine") - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", unexpected_rebuild) - - with pytest.raises(CanarySelectionError, match="zero sessions|zero raw ids|full-source replay"): - run_reindex_canary( - root, - schema_inference_receipt_path=_receipt_path(tmp_path), - sessions_per_origin=1, - no_promote=True, - ) - assert not rebuild_called - - -def test_run_reindex_canary_rejects_missing_receipt_even_with_ambient_valid_receipt( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - ambient_receipt = _write_candidate_receipt(root, tmp_path / "ambient-schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(ambient_receipt)) - - with pytest.raises(CanarySelectionError, match="requires an explicit schema-inference receipt path"): - run_reindex_canary(root, schema_inference_receipt_path=None, sessions_per_origin=1, no_promote=True) - - -@pytest.mark.parametrize("anchor_state", ["missing", "poisoned"]) -def test_run_reindex_canary_cleans_candidate_after_comparison_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, anchor_state: str -) -> None: - """A post-rebuild canary failure cannot strand its inactive candidate.""" - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - receipt_path = _write_candidate_receipt(root, tmp_path / "receipt.json") - anchor = root / ".index-active-pointer" - expected_anchor: bytes | None = None - - def fail_compare(*args: object, **kwargs: object) -> CanaryDiffReport: - nonlocal expected_anchor - del kwargs - if anchor.exists() or anchor.is_symlink(): - anchor.unlink() - if anchor_state == "poisoned": - candidate_path = Path(str(args[1])) - anchor.write_text(str(candidate_path), encoding="utf-8") - expected_anchor = anchor.read_bytes() - raise RuntimeError("synthetic canary comparison failure") - - monkeypatch.setattr(reindex_canary_module, "compare_reindex_generations", fail_compare) - - with pytest.raises(RuntimeError, match="synthetic canary comparison failure"): - run_reindex_canary( - root, - schema_inference_receipt_path=receipt_path, - sessions_per_origin=1, - no_promote=True, - ) - - assert not list((root / ".index-generations").glob("gen-*")) - assert (anchor.read_bytes() if anchor.exists() else None) == expected_anchor - - -def test_run_reindex_canary_refuses_source_rows_without_indexed_sessions(tmp_path: Path) -> None: - """A canary never widens an empty index selection to a full source replay.""" - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - with sqlite3.connect(root / "index.db") as connection: - connection.execute("DELETE FROM sessions") - connection.commit() - receipt_path = _write_candidate_receipt(root, tmp_path / "receipt.json") - - with pytest.raises(CanarySelectionError, match="zero sessions|full-source replay"): - run_reindex_canary( - root, - schema_inference_receipt_path=receipt_path, - sessions_per_origin=1, - no_promote=True, - ) - - -def test_run_reindex_canary_rejects_input_index_outside_archive_root( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "archive" - root.mkdir() - external_index = tmp_path / "external" / "index.db" - external_index.parent.mkdir() - external_index.touch() - receipt_path = _receipt_path(tmp_path) - monkeypatch.setattr("polylogue.config.resolve_archive_root", lambda: tmp_path / "configured-live") - selector_called = False - - def unexpected_selector(*args: object, **kwargs: object) -> None: - nonlocal selector_called - selector_called = True - raise AssertionError("an outside-root input must be rejected before selection") - - monkeypatch.setattr("polylogue.maintenance.reindex_canary.select_canary_sessions", unexpected_selector) - - with pytest.raises(CanarySelectionError, match="inside or bound to the selected archive root"): - run_reindex_canary( - root, input_index=external_index, schema_inference_receipt_path=receipt_path, no_promote=True - ) - assert not selector_called - - -def test_run_reindex_canary_accepts_symlink_farm_active_pointer_through_real_validator( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """An external active index is valid when every archive tier follows it. - - An external index with real durable tiers at ``root`` is the copied-archive - shape and is refused by ``ArchiveLocation``. This fixture keeps the - accepted external-index shape explicit: the configured root is a complete - symlink farm. - """ - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - external_index_root = tmp_path / "external-index-root" - external_index_root.mkdir() - for name in ("source.db", "user.db", "audit.db", "ops.db", "embeddings.db"): - shutil.move(root / name, external_index_root / name) - (root / name).symlink_to(external_index_root / name) - external_index = external_index_root / "index.db" - shutil.move(root / "index.db", external_index) - (root / "index.db").symlink_to(external_index) - (root / ".index-active-pointer").write_text(str(external_index), encoding="utf-8") - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path / "configured-live")) - active_digest = hashlib.sha256(external_index.read_bytes()).hexdigest() - evidence_before = rebuild_source_evidence_snapshot(root) - receipt_path = _write_candidate_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - result = run_reindex_canary( - root, - input_index=external_index, - schema_inference_receipt_path=receipt_path, - sessions_per_origin=1, - no_promote=True, - ) - - receipt = result.rebuild_receipt - generation = receipt["generation"] - assert isinstance(generation, dict) - generation_id = generation["generation_id"] - owner_id = generation["owner_id"] - source_snapshot = generation["source_snapshot"] - candidate_path = Path(str(generation["index_path"])) - expected_candidate_path = external_index_root / ".index-generations" / str(generation_id) / "index.db" - assert result.selection.index_path == external_index - assert result.comparison.current_index.resolve() == external_index.resolve() - assert result.comparison.candidate_index == candidate_path - assert candidate_path == expected_candidate_path.resolve() - assert candidate_path.is_file() - assert generation["archive_root"] == str(root.resolve()) - assert generation["state"] == "inactive" - assert isinstance(owner_id, str) and owner_id - assert isinstance(source_snapshot, str) and source_snapshot - assert source_snapshot == evidence_before == rebuild_source_evidence_snapshot(root) - assert receipt["receipt_schema_version"] == 5 - assert receipt["source_evidence_after"] == rebuild_source_evidence_snapshot(root) - canary_acceptance = receipt["canary_acceptance"] - assert isinstance(canary_acceptance, dict) - assert canary_acceptance["profile"] == "reindex-canary-v2-domain-coverage" - results = canary_acceptance["results"] - assert isinstance(results, list) - assert all(isinstance(result, dict) for result in results) - assert [(result["name"], result["status"]) for result in cast(list[dict[str, object]], results)] == [ - (name, "ok") for name in archive_verification_names_for_route("reindex-canary-candidate") - ] - assert hashlib.sha256(external_index.read_bytes()).hexdigest() == active_digest - assert json.loads((candidate_path.parent / "generation.json").read_text(encoding="utf-8")) == generation - - transaction = receipt["transaction"] - operation = receipt["operation"] - assert transaction is None - assert isinstance(operation, dict) - operation_owner = operation["owner"] - operation_generation = operation["generation"] - operation_delta = operation["delta"] - assert isinstance(operation_owner, dict) - assert isinstance(operation_generation, dict) - assert isinstance(operation_delta, dict) - assert operation_owner["generation_owner_id"] == owner_id - assert operation_generation == {"generation_id": generation_id, "state": "inactive"} - assert operation_delta["transaction_source_snapshot"] == source_snapshot - assert operation_delta["source_snapshot_matches"] is True - - -def test_real_no_promote_rebuild_preserves_remediated_source_state(tmp_path: Path) -> None: - """Candidate replay consumes phase-2 source state without changing it.""" - - root = tmp_path / "archive" - raw_id = _prepare_candidate_ready_archive(root) - with sqlite3.connect(root / "source.db") as connection: - source_state_before = connection.execute( - """ - SELECT parsed_at_ms, parse_error, - (SELECT COUNT(*) FROM blob_refs WHERE ref_id = ? AND ref_type = 'attachment') - FROM raw_sessions WHERE raw_id = ? - """, - (raw_id, raw_id), - ).fetchone() - assert source_state_before is not None - assert source_state_before[0] is not None - assert source_state_before[1] is None - assert source_state_before[2] == 1 - active_digest = hashlib.sha256((root / "index.db").read_bytes()).hexdigest() - evidence_before = rebuild_source_evidence_snapshot(root) - - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest(archive_root=root, promote=False, schema_inference_receipt_path=receipt_path) - ) - - with sqlite3.connect(root / "source.db") as connection: - source_state_after = connection.execute( - """ - SELECT parsed_at_ms, parse_error, - (SELECT COUNT(*) FROM blob_refs WHERE ref_id = ? AND ref_type = 'attachment') - FROM raw_sessions WHERE raw_id = ? - """, - (raw_id, raw_id), - ).fetchone() - assert source_state_after == source_state_before - assert receipt.generation["state"] == "inactive" - assert receipt.generation["source_snapshot"] == evidence_before == rebuild_source_evidence_snapshot(root) - assert receipt.source_evidence_after == rebuild_source_evidence_snapshot(root) - assert hashlib.sha256((root / "index.db").read_bytes()).hexdigest() == active_digest - - -def test_run_reindex_canary_rejects_external_evidence_mutation_after_replay( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A source identity mutation after replay fails before inactive readiness.""" - - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - active_index = root / "index.db" - active_digest = hashlib.sha256(active_index.read_bytes()).hexdigest() - receipt_path = _write_candidate_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path / "configured-live")) - - from polylogue.maintenance import replay as rebuild_replay - - real_replay = rebuild_replay.rebuild_index_from_source - - async def mutate_source_after_replay(*args: Any, **kwargs: Any) -> dict[str, object]: - replay = await real_replay(*args, **kwargs) - with sqlite3.connect(root / "source.db") as connection: - connection.execute("UPDATE raw_sessions SET source_index = source_index + 1") - return replay - - monkeypatch.setattr(rebuild_replay, "rebuild_index_from_source", mutate_source_after_replay) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", _offline_canary_rebuild) - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - run_reindex_canary(root, schema_inference_receipt_path=receipt_path, sessions_per_origin=1, no_promote=True) - - assert hashlib.sha256(active_index.read_bytes()).hexdigest() == active_digest - - -def test_run_reindex_canary_rejects_active_index_rotation_after_replay( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A canary cannot compare against an index that stopped being active.""" - - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - location = ArchiveLocation.resolve(root) - current_index = location.active_index_path - rotated_index = root / "rotated" / "index.db" - rotated_index.parent.mkdir(parents=True) - shutil.copy2(current_index, rotated_index) - receipt_path = _write_candidate_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - def rebuild_then_rotate( - *, - archive_root: Path, - raw_ids: tuple[str, ...], - selected_session_ids: tuple[str, ...], - index_schema_version: int, - schema_inference_receipt_path: Path, - ) -> RebuildIndexReceipt: - result = _offline_canary_rebuild( - archive_root=archive_root, - raw_ids=raw_ids, - selected_session_ids=selected_session_ids, - index_schema_version=index_schema_version, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - (root / ".index-active-pointer").write_text(str(rotated_index), encoding="utf-8") - return result - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", rebuild_then_rotate) - - with pytest.raises(CanarySelectionError, match="active index changed during rebuild"): - run_reindex_canary(root, schema_inference_receipt_path=receipt_path, sessions_per_origin=1, no_promote=True) - - -def test_rebuild_rejects_evidence_mutation_in_deadline_interrupted_pass( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A deferred resumable pass cannot preserve a mutated source proof.""" - - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - - from polylogue.maintenance import replay as rebuild_replay - - async def mutate_source_then_interrupt(*args: Any, **kwargs: Any) -> dict[str, object]: - with sqlite3.connect(root / "source.db") as connection: - connection.execute("UPDATE raw_sessions SET source_path = source_path || '.mutated'") - raise RebuildDeadlineExceededError("synthetic deadline") - - monkeypatch.setattr(rebuild_replay, "rebuild_index_from_source", mutate_source_then_interrupt) - receipt_path = _write_candidate_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - raw_batch_size=10, - pass_deadline_seconds=30.0, - ) - ) - - -def test_run_reindex_canary_does_not_require_zoo_sessions_for_ordinary_archive( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - current = tmp_path / "index.db" - _seed_index(current) - selection = CanarySelection( - index_path=current, - sessions_per_origin=1, - selected_session_ids=("codex-session:alpha",), - selected_raw_ids=("raw-alpha",), - sampled_session_ids=("codex-session:alpha",), - pathology_session_ids=(), - sample_session_ids=(), - origin_counts=(("codex-session", 1),), - ) - captured: dict[str, object] = {} - - class Receipt: - def to_dict(self) -> dict[str, object]: - return {"status": "replayed"} - - def fake_rebuild(**request: object) -> Receipt: - captured["raw_ids"] = tuple(cast(tuple[str, ...], request["raw_ids"])) - captured["has_client_profile"] = "candidate_acceptance_checks" in request - return Receipt() - - def fake_compare( - current_index: Path, - candidate_index: Path, - *, - session_ids: tuple[str, ...], - **provenance: object, - ) -> CanaryDiffReport: - captured["session_ids"] = session_ids - captured.update(provenance) - return _empty_comparison(current_index, candidate_index, session_ids) - - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.select_canary_sessions", lambda *args, **kwargs: selection - ) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", fake_rebuild) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_selection_evidence", lambda *args, **kwargs: None - ) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_canary_candidate", lambda *args, **kwargs: current - ) - monkeypatch.setattr("polylogue.maintenance.reindex_canary.compare_reindex_generations", fake_compare) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_selection_evidence", lambda *args, **kwargs: None - ) - - result = run_reindex_canary( - tmp_path, input_index=current, schema_inference_receipt_path=_receipt_path(tmp_path), no_promote=True - ) - - assert result.selection.pathology_session_ids == () - assert captured["raw_ids"] == ("raw-alpha",) - assert captured["has_client_profile"] is False - - -def test_run_reindex_canary_compares_its_own_inactive_generation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path - current = root / "index.db" - # A real database at a real pre-v44 version: the canary derives its expected - # signatures from the active generation's own declared schema version, so a - # zero-byte placeholder would silently exercise the source_version==0 path. - with sqlite3.connect(current) as connection: - connection.execute("PRAGMA user_version = 43") - generation_id = "gen-canary" - candidate = tmp_path / ".index-generations" / generation_id / "index.db" - candidate.parent.mkdir(parents=True) - candidate.touch() - selection = _test_selection(current) - - class Receipt: - archive_root = str(root.resolve()) - selected_raw_count = len(selection.selected_raw_ids) - status = "replayed" - materialized = True - generation = { - "generation_id": generation_id, - "owner_id": "owner", - "archive_root": str(root.resolve()), - "index_path": str(candidate), - "state": "inactive", - "source_snapshot": "snapshot", - } - - def to_dict(self) -> dict[str, object]: - return {"generation": self.generation} - - captured: dict[str, object] = {} - - def fake_rebuild(**request: object) -> Receipt: - captured["promote"] = False - return Receipt() - - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.select_canary_sessions", lambda *args, **kwargs: selection - ) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", fake_rebuild) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_selection_evidence", lambda *args, **kwargs: None - ) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_authoritative_rebuild_receipt", lambda *args, **kwargs: None - ) - - def fake_compare( - current_path: Path, - candidate_path: Path, - *, - session_ids: tuple[str, ...], - **provenance: object, - ) -> CanaryDiffReport: - captured.update({"paths": (current_path, candidate_path), "session_ids": session_ids}) - captured.update(provenance) - return _empty_comparison(current_path, candidate_path, session_ids) - - monkeypatch.setattr("polylogue.maintenance.reindex_canary.compare_reindex_generations", fake_compare) - - result = run_reindex_canary( - root, input_index=current, schema_inference_receipt_path=_receipt_path(root), no_promote=True - ) - - assert result.comparison.candidate_index == candidate - assert captured["paths"] == (current, candidate) - assert captured["promote"] is False - # The classifier input is derived from the active generation, not ambient - # configuration: v43 is crossed by the packaged v44 title_ref declaration. - assert captured["source_index_version"] == 43 - derived = cast("tuple[DeltaExpectation, ...]", captured["delta_expectations"]) - assert derived - assert all(43 < item.version <= INDEX_SCHEMA_VERSION for item in derived) - assert any(item.table == "sessions" and "title_ref" in item.columns for item in derived) - - -def test_run_reindex_canary_rejects_arbitrary_sqlite_candidate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - current = tmp_path / "index.db" - current.touch() - arbitrary = tmp_path / "arbitrary.db" - arbitrary.touch() - selection = _test_selection(current) - - class Receipt: - archive_root = str(tmp_path.resolve()) - selected_raw_count = len(selection.selected_raw_ids) - status = "replayed" - materialized = True - generation = { - "generation_id": "gen-canary", - "owner_id": "owner", - "archive_root": str(tmp_path.resolve()), - "index_path": str(arbitrary), - "state": "inactive", - "source_snapshot": "snapshot", - } - - def to_dict(self) -> dict[str, object]: - return {"generation": self.generation} - - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary.select_canary_sessions", lambda *args, **kwargs: selection - ) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", lambda **kwargs: Receipt()) - monkeypatch.setattr( - "polylogue.maintenance.reindex_canary._validate_selection_evidence", lambda *args, **kwargs: None - ) - - with pytest.raises(CanarySelectionError, match="outside this archive's generation root"): - run_reindex_canary( - tmp_path, input_index=current, schema_inference_receipt_path=_receipt_path(tmp_path), no_promote=True - ) - - -def test_real_pathology_canary_rejects_cyclic_candidate_before_insight_repair( - pathology_zoo_writable: PathologyZoo, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A corrupt inactive lineage candidate is rejected without touching active data.""" - zoo = pathology_zoo_writable - active_index = zoo.archive_root / "index.db" - active_digest = hashlib.sha256(active_index.read_bytes()).hexdigest() - receipt_path = write_valid_rebuild_receipt(zoo.archive_root, tmp_path / "schema-inference-gate-receipt.json") - monkeypatch.setattr("polylogue.maintenance.pathology_zoo.pathology_zoo_is_present", lambda *args, **kwargs: False) - from polylogue.maintenance import rebuild_index - - real_repopulate = rebuild_index._repopulate_bulk_build_derived_state - - def corrupt_candidate_after_replay(candidate_index: Path) -> dict[str, float]: - timings = real_repopulate(candidate_index) - with sqlite3.connect(candidate_index) as connection: - connection.execute( - "UPDATE sessions SET parent_session_id = ? WHERE session_id = ?", - ("codex-session:zoo-cycle-b", "codex-session:zoo-cycle-a"), - ) - connection.execute( - "UPDATE sessions SET parent_session_id = ? WHERE session_id = ?", - ("codex-session:zoo-cycle-a", "codex-session:zoo-cycle-b"), - ) - connection.commit() - return timings - - def unexpected_insight_repair(*args: object, **kwargs: object) -> object: - raise AssertionError("invalid lineage candidate reached session insight materialization") - - monkeypatch.setattr(rebuild_index, "_repopulate_bulk_build_derived_state", corrupt_candidate_after_replay) - monkeypatch.setattr("polylogue.storage.repair.repair_session_insights", unexpected_insight_repair) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", _offline_canary_rebuild) - - # The subject is that an invalid candidate is REJECTED before session-insight - # materialization -- unexpected_insight_repair above is what actually pins - # that. Which guard rejects it first is incidental, and this alternation has - # always accepted several. - # - # "raw frontier integrity" stopped being reachable here in 4d35f59c4: it - # required byte-proven authority of every `full` raw, but a first-ever - # observation is admitted FULL/ASSERTED by design with no predecessor for a - # byte proof to be about, so every source seen exactly once counted as a - # broken head. With that false positive gone the candidate is now caught by - # the next real guard, current-parser logical-key drift on a frozen raw -- - # which is the grouped-JSONL pathology's own multi-session shape being - # detected, and just as valid a rejection. - with pytest.raises( - RuntimeError, - match=( - "session-lineage-acyclic" - "|no longer parses to one session" - "|raw frontier integrity" - "|re-derived different current-parser logical keys" - ), - ): - run_reindex_canary( - zoo.archive_root, - schema_inference_receipt_path=receipt_path, - pathology_session_ids=("codex-session:zoo-cycle-a", "codex-session:zoo-cycle-b"), - sessions_per_origin=1, - no_promote=True, - ) - - assert hashlib.sha256(active_index.read_bytes()).hexdigest() == active_digest - - -def test_real_daemon_canary_candidate_mutation_reaches_report_writer_red_twin( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """A post-build canonical mutation is observed and cannot bypass review.""" - from polylogue.daemon import bulk_rebuild - - root = tmp_path / "archive" - _prepare_candidate_ready_archive(root) - receipt_path = _write_candidate_receipt(root, tmp_path / "receipt.json") - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - schema_inference_receipt_path=receipt_path, - ) - ) - active_index = ArchiveLocation.resolve(root).active_index_path - active_digest = hashlib.sha256(active_index.read_bytes()).hexdigest() - real_rebuild = _offline_canary_rebuild - - def build_then_mutate( - *, - archive_root: Path, - raw_ids: tuple[str, ...], - selected_session_ids: tuple[str, ...], - index_schema_version: int, - schema_inference_receipt_path: Path, - ) -> Any: - receipt = real_rebuild( - archive_root=archive_root, - raw_ids=raw_ids, - selected_session_ids=selected_session_ids, - index_schema_version=index_schema_version, - schema_inference_receipt_path=schema_inference_receipt_path, - ) - generation = receipt.generation - candidate = Path(str(generation["index_path"])) - with sqlite3.connect(candidate) as connection: - connection.execute("UPDATE blocks SET text = 'post-build candidate mutation'") - connection.commit() - return receipt - - monkeypatch.setattr(bulk_rebuild, "run_daemon_canary_rebuild", build_then_mutate) - result = run_reindex_canary( - root, - schema_inference_receipt_path=receipt_path, - sessions_per_origin=1, - no_promote=True, - ) - - assert hashlib.sha256(active_index.read_bytes()).hexdigest() == active_digest - mutation = next(item for item in result.comparison.differences if item.table == "blocks") - assert mutation.operation is DifferenceOperation.CHANGED - assert mutation.changed_columns == ("text",) - report_path = tmp_path / "reports" / "mutation.json" - with pytest.raises(UnclassifiedCanaryDiffError, match="classification is incomplete"): - write_canary_report( - report_path, - selection=result.selection, - comparison=result.comparison, - rebuild_receipt=result.rebuild_receipt, - reviews=(), - ) - assert not report_path.exists() - - -def test_run_reindex_canary_refuses_foreign_input_index_before_rebuild( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Selection may only read the configured archive's active generation.""" - - archive_root = tmp_path / "archive" - archive_root.mkdir() - active_index = archive_root / "index.db" - foreign_index = tmp_path / "foreign.db" - _seed_index(active_index) - _seed_index(foreign_index) - - def fail_if_rebuild_runs(*args: object, **kwargs: object) -> object: - raise AssertionError("candidate rebuild was invoked with a foreign input index") - - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_canary_rebuild", fail_if_rebuild_runs) - - with pytest.raises(CanarySelectionError, match="configured archive active generation"): - run_reindex_canary( - archive_root, - input_index=foreign_index, - schema_inference_receipt_path=_receipt_path(tmp_path), - no_promote=True, - ) - - -def test_durable_report_persists_unreviewed_discovery_and_refuses_consumption(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - - durable = write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=(), - allow_unreviewed=True, - ) - assert durable.review_status == "unreviewed" - assert json.loads(report_path.read_text(encoding="utf-8"))["review_status"] == "unreviewed" - with pytest.raises(UnclassifiedCanaryDiffError, match="not fully reviewed"): - load_canary_report(report_path) - - -def test_durable_report_persists_explicit_review_for_every_diff(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - - durable = write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - assert durable.unclassified_count == 0 - assert report_path.exists() - payload = json.loads(report_path.read_text(encoding="utf-8")) - assert payload["schema_version"] == 10 - comparison_payload = payload["comparison"] - assert isinstance(comparison_payload, dict) - summary = comparison_payload["summary"] - assert isinstance(summary, dict) - assert summary["unclassified_count"] == 0 - assert summary["unexpected_count"] == len(reviews) - assert "rebuild_receipt" in payload - assert all( - isinstance(review["authority"], dict) and review["authority"]["kind"] == "successor" - for review in payload["reviews"] - ) - - -def test_review_authority_kind_is_bound_to_classification() -> None: - difference = RowDifference( - table="blocks", - operation=DifferenceOperation.CHANGED, - identity=(("block_id", "block"),), - before={"text": "before"}, - after={"text": "after"}, - changed_columns=("text",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unexpected", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="expected canary differences"): - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="successor:polylogue-next", - rationale="wrong authority kind", - ) - with pytest.raises(UnclassifiedCanaryDiffError, match="unexpected canary differences"): - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="delta:33", - rationale="wrong authority kind", - ) - - -def test_review_manifest_rejects_reference_that_disagrees_with_authority(tmp_path: Path) -> None: - """The CLI manifest parser must not silently rewrite an audit authority.""" - - manifest = tmp_path / "reviews.json" - manifest.write_text( - json.dumps( - { - "reviews": [ - { - "table": "blocks", - "operation": "changed", - "identity": {"block_id": "block"}, - "changed_columns": ["text"], - "classification": "expected", - "reference": "delta:33", - "authority": {"kind": "delta", "id": "34"}, - "rationale": "contradictory manifest audit fields", - } - ] - } - ), - encoding="utf-8", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="reference disagrees"): - load_canary_review_manifest(manifest) - - -def test_review_manifest_accepts_packaged_delta_and_nonapproving_successor( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Review loading works outside Git and keeps unresolved differences red.""" - - manifest = tmp_path / "reviews.json" - manifest.write_text( - json.dumps( - { - "reviews": [ - { - "table": "sessions", - "operation": "changed", - "identity": {"session_id": "codex-session:expected"}, - "changed_columns": ["title_ref"], - "classification": "expected", - "reference": "delta:44", - "authority": {"kind": "delta", "id": "44"}, - "rationale": "the packaged title-reprocess delta declares this expected difference", - }, - { - "table": "blocks", - "operation": "changed", - "identity": {"block_id": "successor"}, - "changed_columns": ["text"], - "classification": "unexpected", - "reference": "successor:polylogue-ox2iz", - "authority": {"kind": "successor", "id": "polylogue-ox2iz"}, - "rationale": "open successor owns the unresolved difference", - }, - ] - } - ), - encoding="utf-8", - ) - - monkeypatch.chdir(tmp_path) - reviews = load_canary_review_manifest(manifest) - assert [review.classification for review in reviews] == [ - DifferenceClassification.EXPECTED, - DifferenceClassification.UNEXPECTED, - ] - - -def test_review_manifest_rejects_bead_as_semantic_authority(tmp_path: Path) -> None: - """Planning state cannot authorize a candidate's semantic difference.""" - - manifest = tmp_path / "reviews.json" - manifest.write_text( - json.dumps( - { - "reviews": [ - { - "table": "blocks", - "operation": "changed", - "identity": {"block_id": "unknown"}, - "changed_columns": ["text"], - "classification": "expected", - "reference": "bead:polylogue-does-not-exist", - "authority": {"kind": "bead", "id": "polylogue-does-not-exist"}, - "rationale": "fabricated expected-difference authority", - } - ] - } - ), - encoding="utf-8", - ) - - with pytest.raises(UnclassifiedCanaryDiffError, match="invalid structured authority"): - load_canary_review_manifest(manifest) - - -def test_partial_canary_scopes_thread_membership_by_session_not_thread_aggregate(tmp_path: Path) -> None: - """A selected thread member must not pull un-replayed siblings into the denominator.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current, sessions=("selected", "unselected")) - _seed_index(candidate, sessions=("selected",)) - for index, session_ids in ( - (current, ("codex-session:selected", "codex-session:unselected")), - (candidate, ("codex-session:selected",)), - ): - with sqlite3.connect(index) as connection: - connection.execute( - "INSERT INTO threads(thread_id, session_ids_json, session_count) VALUES (?, ?, ?)", - ("thread-root", json.dumps(session_ids), len(session_ids)), - ) - connection.executemany( - "INSERT INTO thread_sessions(thread_id, session_id, position) VALUES (?, ?, ?)", - (("thread-root", session_id, position) for position, session_id in enumerate(session_ids)), - ) - connection.commit() - - report = compare_reindex_generations(current, candidate, session_ids=("codex-session:selected",)) - - assert all(difference.table != "thread_sessions" for difference in report.differences) - assert "threads" not in report.compared_tables - - -def test_loading_canary_report_rechecks_exact_review_coverage(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - payload = json.loads(report_path.read_text(encoding="utf-8")) - payload["reviews"] = payload["reviews"][:-1] - payload["comparison"]["summary"] = { - "difference_count": 0, - "expected_count": 0, - "unexpected_count": 0, - "unclassified_count": 0, - "counts_by_table": {}, - } - report_path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(UnclassifiedCanaryDiffError, match="review coverage is incomplete"): - load_canary_report(report_path) - - -def test_loading_canary_report_rejects_difference_rationale_that_contradicts_review_authority( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - selection = select_canary_sessions(current, sessions_per_origin=1) - comparison = compare_reindex_generations(current, candidate, session_ids=selection.selected_session_ids) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="successor:polylogue-ox2iz", - rationale="the structured review owns this disposition", - ) - for difference in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - payload = json.loads(report_path.read_text(encoding="utf-8")) - payload["comparison"]["differences"][0]["rationale"] = "bead:unrelated: forged authority text" - report_path.write_text(json.dumps(payload), encoding="utf-8") - monkeypatch.setattr(reindex_canary_module, "_validate_archive_provenance", lambda *args, **kwargs: None) - monkeypatch.setattr(reindex_canary_module, "_validate_authoritative_rebuild_receipt", lambda *args, **kwargs: None) - - with pytest.raises(UnclassifiedCanaryDiffError, match="rationale disagrees"): - load_canary_report(report_path) - - -@pytest.mark.parametrize( - ("field", "value", "message"), - ( - ("schema_version", 4, "no authoritative rebuild receipt schema"), - ("receipt_schema_version", 1, "invalid rebuild receipt"), - ), -) -def test_loading_canary_report_rejects_ambiguous_prior_evidence_schema( - tmp_path: Path, field: str, value: int, message: str -) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - payload = json.loads(report_path.read_text(encoding="utf-8")) - if field == "schema_version": - payload[field] = value - else: - payload["rebuild_receipt"][field] = value - report_path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(UnclassifiedCanaryDiffError, match=message): - load_canary_report(report_path) - - -@pytest.mark.parametrize( - ("mutation", "message"), - ( - ( - "profile", - "acceptance profile does not match", - ), - ( - "results", - "acceptance attestation does not match", - ), - ( - "status", - "acceptance check active-leaf-title-convergence is not ok", - ), - ), -) -def test_loading_canary_report_revalidates_canonical_acceptance_attestation( - tmp_path: Path, mutation: str, message: str -) -> None: - """Consumption rejects a receipt that omits or changes daemon-owned acceptance evidence.""" - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - receipt = _rebuild_receipt(selection, comparison) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=receipt, - reviews=reviews, - ) - payload = json.loads(report_path.read_text(encoding="utf-8")) - persisted_receipt = payload["rebuild_receipt"] - assert isinstance(persisted_receipt, dict) - persisted_acceptance = persisted_receipt["canary_acceptance"] - assert isinstance(persisted_acceptance, dict) - if mutation == "profile": - persisted_acceptance["profile"] = "reindex-canary-v0" - elif mutation == "results": - persisted_acceptance["results"] = [] - else: - results = persisted_acceptance["results"] - assert isinstance(results, list) - assert isinstance(results[0], dict) - results[0]["status"] = "warning" - report_path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(UnclassifiedCanaryDiffError, match=message): - load_canary_report(report_path) - - -def test_loading_canary_report_rejects_tampered_candidate_provenance(tmp_path: Path) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - payload = json.loads(report_path.read_text(encoding="utf-8")) - payload["rebuild_receipt"]["generation"]["index_path"] = str(tmp_path / "foreign.db") - report_path.write_text(json.dumps(payload), encoding="utf-8") - - with pytest.raises(UnclassifiedCanaryDiffError, match="does not identify the compared candidate"): - load_canary_report(report_path) - - -@pytest.mark.parametrize( - ("tamper", "message"), - ( - ("index", "selection index"), - ("sessions", "selection sessions"), - ("raw-ids", "selection does not match the authoritative rebuild receipt"), - ), -) -def test_loading_canary_report_rejects_tampered_selection_binding(tmp_path: Path, tamper: str, message: str) -> None: - current, candidate, report_path = tmp_path / "current.db", tmp_path / "candidate.db", tmp_path / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed") - comparison, selection = ( - compare_reindex_generations(current, candidate), - select_canary_sessions(current, sessions_per_origin=1), - ) - reviews = tuple( - CanaryDifferenceReview.for_difference( - item, classification=DifferenceClassification.UNEXPECTED, reference="polylogue-ox2iz", rationale="r" - ) - for item in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - payload = json.loads(report_path.read_text(encoding="utf-8")) - if tamper == "index": - payload["selection"]["index_path"] = str(tmp_path / "foreign.db") - elif tamper == "sessions": - payload["selection"]["selected_session_ids"] = ["codex-session:foreign"] - else: - payload["selection"]["selected_raw_ids"] = ["raw-foreign"] - report_path.write_text(json.dumps(payload)) - with pytest.raises(UnclassifiedCanaryDiffError, match=message): - load_canary_report(report_path) - - -def test_loading_canary_report_recomputes_tampered_summary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - payload = json.loads(report_path.read_text(encoding="utf-8")) - payload["comparison"]["summary"] = { - "difference_count": 0, - "expected_count": 0, - "unexpected_count": 0, - "unclassified_count": 0, - "counts_by_table": {}, - } - report_path.write_text(json.dumps(payload), encoding="utf-8") - monkeypatch.setattr(reindex_canary_module, "_validate_archive_provenance", lambda *args, **kwargs: None) - - loaded = load_canary_report(report_path) - - comparison_payload = loaded["comparison"] - assert isinstance(comparison_payload, dict) - summary = comparison_payload["summary"] - assert isinstance(summary, dict) - assert summary["difference_count"] == len(reviews) - assert summary["unexpected_count"] == len(reviews) - - -def test_canary_report_uses_unique_temporary_names(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - original = tempfile.NamedTemporaryFile - names: list[str] = [] - - def recording_named_temporary_file(*args: Any, **kwargs: Any) -> Any: - stream = original(*args, **kwargs) - names.append(stream.name) - return stream - - monkeypatch.setattr(tempfile, "NamedTemporaryFile", recording_named_temporary_file) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - assert len(names) == 2 - assert len(set(names)) == 2 - assert list(report_path.parent.glob(f".{report_path.name}.*.tmp")) == [] - - -def test_canary_report_cleans_temporary_file_when_replace_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - report_path = tmp_path / "reports" / "canary.json" - _seed_index(current) - _seed_index(candidate, block_text="changed transcript") - comparison = compare_reindex_generations(current, candidate) - selection = select_canary_sessions(current, sessions_per_origin=1) - reviews = tuple( - CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.UNEXPECTED, - reference="polylogue-ox2iz", - rationale="the canary has no reviewed expected delta for this row", - ) - for difference in comparison.differences - ) - - def fail_replace(source: object, destination: object) -> None: - raise OSError("replace failed") - - monkeypatch.setattr(os, "replace", fail_replace) - with pytest.raises(OSError, match="replace failed"): - write_canary_report( - report_path, - selection=selection, - comparison=comparison, - rebuild_receipt=_rebuild_receipt(selection, comparison), - reviews=reviews, - ) - - assert not report_path.exists() - assert list(report_path.parent.glob(f".{report_path.name}.*.tmp")) == [] - - -def _semantic_declaration( - version: int, - *, - table: str, - columns: tuple[str, ...], - operations: tuple[CanaryChangeOperation, ...] = ("changed",), - scope: TargetedReprocessScope | None = None, -) -> IndexDeltaDeclaration: - """Build one packaged-shape declaration for classifier tests. - - Real declarations are product data that changes as the schema advances, so - the classifier tests state their own input rather than binding to whichever - live delta happens to carry a signature today. - """ - - classes = ( - (DerivedDeltaClass.SHAPE_FORWARD_TARGETED_REPROCESS,) - if scope is not None - else (DerivedDeltaClass.SEMANTIC_REPARSE,) - ) - return IndexDeltaDeclaration( - version=version, - classes=classes, - reprocess_scope=scope, - expected_canary_changes=(ExpectedCanaryChange(table=table, operations=operations, columns=columns),), - ) - - -def test_schema_and_row_canary_signatures_are_distinct() -> None: - """Packaged authority must distinguish DDL differences from replay rows.""" - declaration = _semantic_declaration(71, table="sessions", columns=("title_ref", "title_confidence")) - schema = ExpectedCanaryChange( - table="sessions", operations=("added",), columns=("title_ref", "title_confidence"), scope="schema" - ) - row = ExpectedCanaryChange( - table="sessions", operations=("changed",), columns=("title_ref", "title_confidence"), scope="row" - ) - assert schema.scope == "schema" - assert row.scope == "row" - assert invalid_canary_change_declarations((replace_declaration(declaration, (schema, row)),)) == () - - -def replace_declaration( - declaration: IndexDeltaDeclaration, changes: tuple[ExpectedCanaryChange, ...] -) -> IndexDeltaDeclaration: - return IndexDeltaDeclaration( - version=declaration.version, - classes=declaration.classes, - reprocess_scope=declaration.reprocess_scope, - expected_canary_changes=changes, - ) - - -def test_invalid_canary_signature_is_rejected_by_lifecycle_policy() -> None: - declaration = _semantic_declaration(71, table="sessions", columns=("not_a_real_column",)) - assert invalid_canary_change_declarations((declaration,)) == ( - (71, "unknown column(s) on sessions: not_a_real_column"), - ) - - -def test_declared_semantic_delta_classifies_its_own_predicted_difference(tmp_path: Path) -> None: - """The crossed deltas -- not a hand-written manifest -- account for the diff.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate, profile_message_count=2) - - expectations = index_delta_expectations( - 40, - 41, - declarations=(_semantic_declaration(41, table="session_profiles", columns=("message_count",)),), - ) - report = compare_reindex_generations(current, candidate, delta_expectations=expectations) - - profile_changes = [item for item in report.differences if item.table == "session_profiles"] - assert profile_changes - assert all(item.classification is DifferenceClassification.EXPECTED for item in profile_changes) - assert all("index delta 41" in item.rationale for item in profile_changes) - assert report.unexpected_count == 0 - - -def test_declared_delta_does_not_swallow_a_planted_undeclared_change(tmp_path: Path) -> None: - """Anti-vacuity (Ref polylogue-tjr4z): a real semantic diff stays unexpected. - - The planted difference is a genuine read-model change on a column no crossed - delta declares, on the *same table* a delta does declare. A classifier that - matched by table -- or that reported a fabricated zero -- would call this - expected; the real comparator must surface it. - """ - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate, block_text="planted divergent transcript") - with sqlite3.connect(candidate) as connection: - connection.execute("UPDATE session_profiles SET tags_json = ?", ('{"planted":true}',)) - connection.commit() - - expectations = index_delta_expectations( - 40, - 41, - declarations=(_semantic_declaration(41, table="session_profiles", columns=("message_count",)),), - ) - report = compare_reindex_generations(current, candidate, delta_expectations=expectations) - - planted = [item for item in report.differences if item.table in {"blocks", "session_profiles"}] - assert {item.table for item in planted} == {"blocks", "session_profiles"} - assert all(item.classification is DifferenceClassification.UNEXPECTED for item in planted) - assert report.unexpected_count >= 2 - assert report.expected_count == 0 - - -def test_declared_delta_cannot_absorb_a_row_that_also_changed_an_undeclared_column(tmp_path: Path) -> None: - """A partially declared row is unexpected in whole, never partly waived.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - _seed_index(current) - _seed_index(candidate, profile_message_count=2) - with sqlite3.connect(candidate) as connection: - connection.execute("UPDATE session_profiles SET tags_json = ?", ('{"extra":true}',)) - connection.commit() - - expectations = index_delta_expectations( - 40, - 41, - declarations=(_semantic_declaration(41, table="session_profiles", columns=("message_count",)),), - ) - report = compare_reindex_generations(current, candidate, delta_expectations=expectations) - - profile_changes = [item for item in report.differences if item.table == "session_profiles"] - assert len(profile_changes) == 1 - assert set(profile_changes[0].changed_columns) == {"tags_json", "message_count"} - assert profile_changes[0].classification is DifferenceClassification.UNEXPECTED - - -def test_declared_delta_scope_does_not_reach_another_origin(tmp_path: Path) -> None: - """A targeted reprocess authorizes only the population it names.""" - - current = tmp_path / "current.db" - candidate = tmp_path / "candidate.db" - sessions = ("alpha", "beta") - origins = ("codex-session", "chatgpt-export") - _seed_index(current, sessions=sessions, origins=origins) - _seed_index(candidate, sessions=sessions, origins=origins, profile_message_count=2) - - expectations = index_delta_expectations( - 40, - 41, - declarations=( - _semantic_declaration( - 41, - table="session_profiles", - columns=("message_count",), - scope=TargetedReprocessScope(origin="codex-session"), - ), - ), - ) - report = compare_reindex_generations(current, candidate, delta_expectations=expectations) - - by_session = { - str(dict(item.identity)["session_id"]): item for item in report.differences if item.table == "session_profiles" - } - assert by_session["codex-session:alpha"].classification is DifferenceClassification.EXPECTED - assert by_session["chatgpt-export:beta"].classification is DifferenceClassification.UNEXPECTED - - -def test_shape_only_delta_contributes_no_expectations() -> None: - """Only declarations that claim semantic work can authorize a row change.""" - - shape_only = IndexDeltaDeclaration( - version=41, - classes=(DerivedDeltaClass.VIEW_ONLY,), - operations=( - FastForwardOperation( - name="v41-view-only", - kind=FastForwardOperationKind.REPLACE_VIEW, - objects=(("view", "actions"),), - ), - ), - ) - - assert index_delta_expectations(40, 41, declarations=(shape_only,)) == () - - -def test_semantic_reparse_delta_can_authorize_a_reviewed_difference(monkeypatch: pytest.MonkeyPatch) -> None: - """A semantic delta ships no fast-forward SQL, and still declares table scope. - - Sourcing comparable table scope only from ``operations`` made every - SEMANTIC_REPARSE declaration unable to approve anything, because that class - routes to a full rebuild and declares no SQL surface at all. - """ - - declaration = _semantic_declaration(41, table="session_profiles", columns=("message_count",)) - difference = RowDifference( - table="session_profiles", - operation=DifferenceOperation.CHANGED, - identity=(("session_id", "codex-session:alpha"),), - before={"message_count": 1}, - after={"message_count": 2}, - changed_columns=("message_count",), - classification=DifferenceClassification.UNEXPECTED, - rationale="unreviewed", - ) - review = CanaryDifferenceReview.for_difference( - difference, - classification=DifferenceClassification.EXPECTED, - reference="delta:41", - rationale="the declared semantic reparse recomputes this aggregate", - ) - - monkeypatch.setattr(lifecycle_module, "INDEX_DELTA_DECLARATIONS", (declaration,)) - reindex_canary_module._validate_expected_review_authorities((review,)) - - -def test_undeclared_crossed_versions_are_reported_not_silently_expected() -> None: - """A crossed version with no declaration authorizes nothing, and says so.""" - - declarations = (_semantic_declaration(41, table="session_profiles", columns=("message_count",)),) - - assert index_delta_expectations(40, 43, declarations=declarations) == ( - DeltaExpectation( - version=41, - table="session_profiles", - operations=(DifferenceOperation.CHANGED,), - columns=("message_count",), - ), - ) - assert undeclared_index_delta_versions(40, 43, declarations) == (42, 43) diff --git a/tests/unit/maintenance/test_sharded_rebuild.py b/tests/unit/maintenance/test_sharded_rebuild.py deleted file mode 100644 index b102e74ee2..0000000000 --- a/tests/unit/maintenance/test_sharded_rebuild.py +++ /dev/null @@ -1,429 +0,0 @@ -"""Sharded from-empty index rebuild (polylogue-pzxm). - -Two things are tested at the function level here (fast, no full rebuild -engine involved): - -- :func:`shard_raw_ids` -- deterministic, exhaustive, non-overlapping - partitioning. -- The cross-shard hard part the bead calls out explicitly: a parent/child - pair whose two sessions are written into DIFFERENT shard databases never - gets to resolve `session_links`/`parent_session_id`/`root_session_id` - during either shard's own replay (the counterpart row does not exist in - that shard yet). :func:`merge_shards_into_target` + - :func:`resolve_cross_shard_session_graph` must produce the exact same - resolved state a single-writer replay of both sessions (in either order) - would have produced. - -The full engine-level equivalence proof (sequential vs. sharded -``rebuild_index_from_source_sync``, PR #3469 MANIFESTS IDENTICAL pattern) and -the K=4/8 benchmark live in ``tests/benchmarks/test_sharded_rebuild.py`` -- -both drive the real rebuild engine end to end and are slower. -""" - -from __future__ import annotations - -import json -import sqlite3 -import threading -from pathlib import Path -from typing import Any, cast - -import pytest - -import polylogue.maintenance.rebuild_index as rebuild_index_module -import polylogue.maintenance.sharded_rebuild as sharded_rebuild_module -from polylogue.archive.message.roles import Role -from polylogue.core.enums import BlockType, BranchType, Provider -from polylogue.maintenance.rebuild_index import ( - RebuildIndexRequest, - RebuildProvenanceContext, - rebuild_index_from_source_sync, -) -from polylogue.maintenance.schema_inference_gate import ( - rebuild_source_revision_snapshot, - validate_schema_inference_receipt, -) -from polylogue.maintenance.sharded_rebuild import ( - merge_shards_into_target, - resolve_cross_shard_session_graph, - shard_raw_ids, -) -from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.index_generation import IndexGeneration, IndexGenerationStore -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_tier -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - - -class _NoopProvenance: - def validate(self) -> None: - return - - def validate_cleanup(self) -> None: - return - - -_NOOP_PROVENANCE = _NoopProvenance() - - -def _connect(path: Path) -> sqlite3.Connection: - conn = sqlite3.connect(path) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - initialize_archive_tier(conn, ArchiveTier.INDEX) - return conn - - -def _raw_payload(native_id: str, text: str) -> bytes: - rows = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-08-05T10:00:00Z"}}, - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m0", - "role": "user", - "content": [{"type": "input_text", "text": text}], - }, - }, - ] - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_raw_archive(root: Path, count: int = 8) -> list[str]: - initialize_active_archive_root(root) - paths = [] - for index in range(count): - path = root / "wire" / "current" / f"{index}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(_raw_payload(f"sharded-route-{index}", f"sharded route text {index}")) - paths.append(path) - result = admit_provider_source_packages(root, (provider_source_package("codex", (path,)) for path in paths)) - assert getattr(result, "parse_failures", 0) == 0 - backfill_historical_revision_evidence(root, ingest_workers=1) - with sqlite3.connect(root / "source.db") as conn: - return [str(row[0]) for row in conn.execute("SELECT raw_id FROM raw_sessions ORDER BY raw_id")] - - -def test_shard_raw_ids_is_deterministic_exhaustive_and_disjoint(tmp_path: Path) -> None: - raw_ids = [f"raw-{i}" for i in range(200)] - # No source.db at tmp_path -- shard_raw_ids falls back to raw_id as its - # own cohort key, exercising the no-cohort-evidence path deliberately. - first = shard_raw_ids(tmp_path, raw_ids, 8) - second = shard_raw_ids(tmp_path, raw_ids, 8) - assert first == second # deterministic: same hash, same bucket every call - assert len(first) == 8 - flattened = [raw_id for bucket in first for raw_id in bucket] - assert sorted(flattened) == sorted(raw_ids) # exhaustive - assert len(set(flattened)) == len(raw_ids) # disjoint (no duplication) - # Every bucket gets some share of a 200-item population across 8 buckets - # (not a strict balance guarantee, but a degenerate all-in-one-bucket - # hash would be a real bug worth catching here). - assert all(bucket for bucket in first) - - -def test_shard_raw_ids_rejects_non_positive_shard_count(tmp_path: Path) -> None: - import pytest - - with pytest.raises(ValueError, match="shard_count"): - shard_raw_ids(tmp_path, ["a"], 0) - - -def _parent_session() -> ParsedSession: - return ParsedSession( - source_name=Provider.CLAUDE_CODE, - provider_session_id="parent-session", - updated_at="2026-01-01T00:00:01+00:00", - messages=[ - ParsedMessage( - provider_message_id="p1", - role=Role.USER, - blocks=[ParsedContentBlock(type=BlockType.TEXT, text="parent")], - ) - ], - ) - - -def _child_session() -> ParsedSession: - return ParsedSession( - source_name=Provider.CLAUDE_CODE, - provider_session_id="child-session", - parent_session_provider_id="parent-session", - branch_type=BranchType.SIDECHAIN, - updated_at="2026-01-01T00:00:02+00:00", - messages=[ - ParsedMessage( - provider_message_id="c1", - role=Role.USER, - blocks=[ParsedContentBlock(type=BlockType.TEXT, text="child")], - ) - ], - ) - - -def test_cross_shard_parent_child_resolves_after_merge(tmp_path: Path) -> None: - """The polylogue-pzxm 'hard part': a fork split across two shards. - - Shard A gets only the parent; shard B gets only the child. Neither - shard's own replay can resolve the link (the counterpart row does not - exist locally), matching - ``test_archive_tiers_writer_stores_unresolved_link_when_parent_absent``'s - single-writer baseline for a still-missing parent. After merge + one - ``resolve_cross_shard_session_graph`` pass, the result must match - ``test_archive_tiers_writer_resolves_parent_link_when_parent_already_exists``'s - single-writer baseline for an already-present parent. - """ - shard_a = tmp_path / "shard-a" / "index.db" - shard_b = tmp_path / "shard-b" / "index.db" - shard_a.parent.mkdir(parents=True) - shard_b.parent.mkdir(parents=True) - - conn_a = _connect(shard_a) - parent_id = write_parsed_session_to_archive(conn_a, _parent_session(), bulk_fts=True, bulk_build=True) - conn_a.commit() - conn_a.close() - - conn_b = _connect(shard_b) - child_id = write_parsed_session_to_archive(conn_b, _child_session(), bulk_fts=True, bulk_build=True) - conn_b.commit() - child_row_before = conn_b.execute( - "SELECT parent_session_id, root_session_id FROM sessions WHERE session_id = ?", (child_id,) - ).fetchone() - link_row_before = conn_b.execute( - "SELECT resolved_dst_session_id FROM session_links WHERE src_session_id = ?", (child_id,) - ).fetchone() - conn_b.close() - - # Sanity: within its own shard, the child's link is genuinely unresolved - # -- this is the gap the merge + resolve pass must close, not a no-op. - assert child_row_before["parent_session_id"] is None - assert link_row_before["resolved_dst_session_id"] is None - - target = tmp_path / "target" / "index.db" - target.parent.mkdir(parents=True) - conn_target = _connect(target) - conn_target.commit() - conn_target.close() - - merge_shards_into_target(target, [shard_a, shard_b], provenance=cast(RebuildProvenanceContext, _NOOP_PROVENANCE)) - resolve_cross_shard_session_graph(target, provenance=cast(RebuildProvenanceContext, _NOOP_PROVENANCE)) - - with sqlite3.connect(target) as conn: - conn.row_factory = sqlite3.Row - child_row = conn.execute( - "SELECT parent_session_id, root_session_id, branch_type FROM sessions WHERE session_id = ?", - (child_id,), - ).fetchone() - link_row = conn.execute( - "SELECT resolved_dst_session_id, status FROM session_links WHERE src_session_id = ?", - (child_id,), - ).fetchone() - thread_rows = conn.execute( - "SELECT session_id, position FROM thread_sessions WHERE thread_id = ? ORDER BY position", - (parent_id,), - ).fetchall() - - assert dict(child_row) == { - "parent_session_id": parent_id, - "root_session_id": parent_id, - "branch_type": "sidechain", - } - assert dict(link_row) == {"resolved_dst_session_id": parent_id, "status": None} - assert [dict(row) for row in thread_rows] == [ - {"session_id": parent_id, "position": 0}, - {"session_id": child_id, "position": 1}, - ] - - -def test_merge_shards_into_target_raises_on_foreign_key_violation(tmp_path: Path) -> None: - """A shard whose merged content would dangle an FK must fail loudly, not silently promote.""" - import pytest - - shard_a = tmp_path / "shard-a" / "index.db" - shard_a.parent.mkdir(parents=True) - conn_a = _connect(shard_a) - write_parsed_session_to_archive(conn_a, _child_session(), bulk_fts=True, bulk_build=True) - conn_a.commit() - # Corrupt the shard by pointing the child's session_links row at a - # resolved parent that does not exist in ANY shard -- this must never - # happen from a real replay (resolution only ever sets - # resolved_dst_session_id to an existing row's id), but proves the - # foreign_key_check safety net actually fires rather than being dead - # code. - conn_a.execute("PRAGMA foreign_keys = OFF") - conn_a.execute( - "UPDATE sessions SET parent_session_id = 'claude-code-session:missing-parent' WHERE native_id = ?", - ("child-session",), - ) - conn_a.commit() - conn_a.close() - - target = tmp_path / "target" / "index.db" - target.parent.mkdir(parents=True) - conn_target = _connect(target) - conn_target.commit() - conn_target.close() - - with pytest.raises(RuntimeError, match="foreign-key violations"): - merge_shards_into_target(target, [shard_a], provenance=cast(RebuildProvenanceContext, _NOOP_PROVENANCE)) - - -def test_merge_revalidates_external_evidence_before_target_mutation(tmp_path: Path) -> None: - """A source-evidence change at the merge boundary leaves the target empty. - - Anti-vacuity: this invokes the production ``merge_shards_into_target`` with - real index rows and a real receipt context. Removing its pre-insert - provenance validation lets the shard row reach ``target`` before the - caller can observe the drift. - """ - root = tmp_path / "archive" - initialize_active_archive_root(root) - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.write_raw_payload( - provider=Provider.CODEX, - payload=b'{"type":"session_meta","payload":{"id":"receipt-source"}}\n', - source_path="receipt-source.jsonl", - acquired_at_ms=1, - ) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - evidence = validate_schema_inference_receipt(root, receipt_path) - provenance = RebuildProvenanceContext( - root=root, - receipt_path=receipt_path, - source_snapshot=rebuild_source_revision_snapshot(root), - consumed_evidence=evidence, - ) - - shard = tmp_path / "shard" / "index.db" - shard.parent.mkdir(parents=True) - conn = _connect(shard) - write_parsed_session_to_archive(conn, _parent_session(), bulk_fts=True, bulk_build=True) - conn.commit() - conn.close() - target = tmp_path / "target" / "index.db" - target.parent.mkdir(parents=True) - conn = _connect(target) - conn.commit() - conn.close() - - receipt = json.loads(receipt_path.read_text(encoding="utf-8")) - origin = receipt["ground_truth_inputs"]["origins"]["codex-session"] - external_path = Path(origin["declared_roots"][0]) / origin["external_inventory"][0]["relative_path"] - external_path.write_bytes(b"drifted-before-merge") - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed"): - merge_shards_into_target(target, [shard], provenance=provenance) - - with sqlite3.connect(target) as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] == 0 - - -@pytest.mark.parametrize("discard_failure", ["exception", "false"], ids=["discard-exception", "discard-false"]) -def test_sharded_route_cleans_every_sibling_after_post_graph_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, discard_failure: str -) -> None: - """The real sharded route cleans every sibling after graph provenance fails. - - Anti-vacuity: this drives ``rebuild_index_from_source_sync`` through - ``replay_selected_raw_ids_sharded`` with real source rows and real shard - replay. The real graph resolver completes before the receipt is expired; - the post-graph validation then fails. Removing sibling cleanup, the - independent-discard handling, or the primary-error preservation leaves - shard metadata behind, skips a sibling, or hides the graph failure. - """ - root = tmp_path / "archive" - raw_ids = _seed_raw_archive(root) - receipt_path = write_valid_rebuild_receipt(root, tmp_path / "receipt.json") - original_graph_resolution = sharded_rebuild_module.resolve_cross_shard_session_graph - original_create = IndexGenerationStore.create - original_discard = IndexGenerationStore.discard_if_inactive - created_generation_ids: list[str] = [] - failed_shard_id: str | None = None - discard_calls: list[str] = [] - repopulate_calls: list[Path] = [] - insight_calls: list[object] = [] - source_write_lock = threading.Lock() - - from polylogue.sources import revision_backfill as revision_backfill_module - - original_backfill = cast(Any, revision_backfill_module.backfill_historical_revision_evidence) - - def serialize_backfill(*args: object, **kwargs: object) -> object: - with source_write_lock: - return original_backfill(*args, **kwargs) - - monkeypatch.setattr(revision_backfill_module, "backfill_historical_revision_evidence", serialize_backfill) - - def record_create( - store: IndexGenerationStore, *, owner_id: str | None = None, source_snapshot: str - ) -> IndexGeneration: - nonlocal failed_shard_id - generation = original_create(store, owner_id=owner_id, source_snapshot=source_snapshot) - created_generation_ids.append(generation.generation_id) - if len(created_generation_ids) == 2: - failed_shard_id = generation.generation_id - return generation - - def fail_one_discard(generation_store: IndexGenerationStore, generation: IndexGeneration) -> bool: - generation_id = generation.generation_id - discard_calls.append(generation_id) - if generation_id == failed_shard_id: - if discard_failure == "exception": - raise OSError("synthetic shard cleanup failure") - return False - return original_discard(generation_store, generation) - - def fail_after_graph(target_index_path: Path, *, provenance: RebuildProvenanceContext) -> float: - graph_elapsed_s = original_graph_resolution(target_index_path, provenance=provenance) - payload = json.loads(receipt_path.read_text(encoding="utf-8")) - payload["generated_at"] = "2000-01-01T00:00:00Z" - receipt_path.write_text(json.dumps(payload), encoding="utf-8") - provenance.validate() - return graph_elapsed_s - - def unexpected_repopulate(index_path: Path) -> dict[str, float]: - repopulate_calls.append(index_path) - raise AssertionError("post-graph provenance failure reached derived-state repopulation") - - def unexpected_insight_repair(*args: object, **kwargs: object) -> object: - insight_calls.append((args, kwargs)) - raise AssertionError("post-graph provenance failure reached insight repair") - - monkeypatch.setattr(sharded_rebuild_module, "resolve_cross_shard_session_graph", fail_after_graph) - monkeypatch.setattr(IndexGenerationStore, "create", record_create) - monkeypatch.setattr(IndexGenerationStore, "discard_if_inactive", fail_one_discard) - monkeypatch.setattr(rebuild_index_module, "_repopulate_bulk_build_derived_state", unexpected_repopulate) - monkeypatch.setattr("polylogue.storage.repair.repair_session_insights", unexpected_insight_repair) - - buckets = [bucket for bucket in shard_raw_ids(root, raw_ids, 2) if bucket] - assert len(buckets) == 2 - - with pytest.raises(RuntimeError, match="schema-inference preflight gate failed") as raised: - rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - schema_inference_receipt_path=receipt_path, - shard_count=2, - ) - ) - - assert failed_shard_id is not None - shard_ids = set(created_generation_ids[1:]) - assert len(shard_ids) == len(buckets) - assert failed_shard_id in shard_ids - assert shard_ids <= set(discard_calls) - assert all(discard_calls.count(generation_id) == 1 for generation_id in shard_ids) - assert {path.name for path in (root / ".index-generations").glob("gen-*")} == {failed_shard_id} - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - assert repopulate_calls == [] - assert insight_calls == [] - assert str(raised.value).startswith("rebuild schema-inference preflight gate failed") - notes = "\n".join(raised.value.__notes__ or ()) - assert "shard cleanup also failed" in notes - assert failed_shard_id in notes - expected_detail = "synthetic shard cleanup failure" if discard_failure == "exception" else "was not discarded" - assert expected_detail in notes diff --git a/tests/unit/storage/test_planner_statistics_seed.py b/tests/unit/storage/test_planner_statistics_seed.py deleted file mode 100644 index 558aa69811..0000000000 --- a/tests/unit/storage/test_planner_statistics_seed.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Fresh index bootstrap must give the planner correct relative selectivities. - -A database without ``sqlite_stat1`` makes the query planner prefer -low-cardinality equality indexes (``idx_blocks_type_tool``) over -session-scoped ones for writer-hot maintenance queries: the per-session -``action_pairs`` refresh then scans the archive's entire ``tool_use`` -population on every session write — O(N^2) over a bulk rebuild, measured live -at >20x replay slowdown (polylogue-l3tk, 2026-07-19). Bootstrap therefore -seeds representative statistics so plans are correct from the first write. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path - -import aiosqlite -import pytest - -from polylogue.maintenance.rebuild_index import ( - _refresh_generation_planner_statistics, - _should_refresh_generation_planner_statistics, -) -from polylogue.storage.sqlite.action_pairs import action_pairs_refresh_sql -from polylogue.storage.sqlite.schema import _ensure_schema, ensure_schema_async - - -def _block_search_plans(conn: sqlite3.Connection) -> list[str]: - rows = conn.execute( - "EXPLAIN QUERY PLAN " + action_pairs_refresh_sql("?"), - ("session", "session", "session"), - ).fetchall() - return [str(row[3]) for row in rows if "idx_blocks" in str(row[3])] - - -def test_fresh_bootstrap_seeds_planner_statistics(tmp_path: Path) -> None: - conn = sqlite3.connect(tmp_path / "index.db") - try: - _ensure_schema(conn) - seeded = conn.execute( - "SELECT count(*) FROM sqlite_stat1 WHERE tbl IN ('blocks', 'messages', 'action_pairs')" - ).fetchone()[0] - assert seeded > 0 - finally: - conn.close() - - -def test_fresh_bootstrap_plans_session_scoped_action_pairs_refresh(tmp_path: Path) -> None: - """Without seeded stats this exact query planned three full - ``idx_blocks_type_tool (block_type=?)`` scans on a fresh database.""" - conn = sqlite3.connect(tmp_path / "index.db") - try: - _ensure_schema(conn) - plans = _block_search_plans(conn) - assert plans, "expected block search steps in the action-pairs refresh plan" - assert all("idx_blocks_session_position" in step for step in plans), plans - finally: - conn.close() - - -@pytest.mark.asyncio -async def test_async_bootstrap_seeds_planner_statistics(tmp_path: Path) -> None: - async with aiosqlite.connect(tmp_path / "index.db") as conn: - await ensure_schema_async(conn) - cursor = await conn.execute("SELECT count(*) FROM sqlite_stat1 WHERE tbl = 'blocks'") - row = await cursor.fetchone() - assert row is not None and row[0] > 0 - - -def test_refresh_generation_planner_statistics_measures_real_tables(tmp_path: Path) -> None: - db_path = tmp_path / "index.db" - conn = sqlite3.connect(db_path) - try: - _ensure_schema(conn) - conn.execute("DELETE FROM sqlite_stat1") - conn.commit() - finally: - conn.close() - - _refresh_generation_planner_statistics(db_path) - - conn = sqlite3.connect(db_path) - try: - measured_tables = {str(row[0]) for row in conn.execute("SELECT DISTINCT tbl FROM sqlite_stat1").fetchall()} - assert {"sessions", "messages", "blocks", "session_links", "action_pairs"} <= measured_tables - # During bulk replay these stores are intentionally empty until final - # readiness; sampling their backing tables would only add I/O. - assert "messages_fts_data" not in measured_tables - assert "blocks_command_trigram_data" not in measured_tables - finally: - conn.close() - - -def test_refresh_generation_planner_statistics_tolerates_missing_file(tmp_path: Path) -> None: - _refresh_generation_planner_statistics(tmp_path / "missing" / "index.db") - - -def test_rebuild_statistics_refreshes_initial_and_periodic_tranches() -> None: - """The actual rebuild policy avoids archive-wide ANALYZE per resume page.""" - assert _should_refresh_generation_planner_statistics(processed_before=None, processed_after=100) - assert not _should_refresh_generation_planner_statistics(processed_before=0, processed_after=100) - assert _should_refresh_generation_planner_statistics(processed_before=900, processed_after=1_000) - assert not _should_refresh_generation_planner_statistics(processed_before=32_100, processed_after=32_200) - assert _should_refresh_generation_planner_statistics(processed_before=32_900, processed_after=33_000) diff --git a/tests/unit/storage/test_rebuild_paging_content_order.py b/tests/unit/storage/test_rebuild_paging_content_order.py deleted file mode 100644 index 5d81880f52..0000000000 --- a/tests/unit/storage/test_rebuild_paging_content_order.py +++ /dev/null @@ -1,335 +0,0 @@ -"""polylogue-hord: rebuild paging orders by content, not acquisition time. - -Background (see ``IndexGenerationStore.next_raw_page`` and -``RawParsePrefetchCache`` in ``polylogue.sources.revision_backfill``): a full -index rebuild reparses duplicate content. Two independent mechanisms can -avoid reparsing a byte-identical duplicate raw: - -1. ``_parse_retained_raws``'s own ``(provider, blob_hash, dedup_path)`` - grouping, applied to whatever raw_id list ONE call receives. -2. The cross-call content cache layered on ``RawParsePrefetchCache`` - (``get_content``/``put_content``), consulted by every call regardless of - how many raw_ids it covers, and shared by every raw-materialization - caller that threads the SAME cache instance across many bounded passes - -- today that is specifically the daemon's automagic bulk-rebuild route - (``daemon/bulk_rebuild.py``, one ``RawParsePrefetchCache`` instance per - generation, threaded through every tick). - -Investigation finding (empirically confirmed, not assumed): for a raw whose -duplicate sibling was already discovered on an EARLIER classification pass --- durably recorded as a shared ``raw_session_memberships.logical_source_key`` --- ``ArchiveStore.raw_membership_selection_components``/ -``expand_raw_membership_selection`` pull the WHOLE linked cohort into ONE -``_parse_retained_raws`` call regardless of which page or acquisition-time -bucket triggered it, so mechanism (1) alone already dedupes it -- paging -order is irrelevant there. The order-sensitive case is a raw being -classified for the FIRST time (no ``raw_session_memberships`` row yet): its -census selection is a graph-singleton (nothing durable links it to its -duplicate yet), so ``_parse_retained_raws`` is invoked separately for each -duplicate member, and ONLY mechanism (2) -- the content cache -- can still -avoid a second parse, and only if the duplicate's earlier call is still -resident when the later one runs. Paging ``ORDER BY acquired_at_ms, raw_id`` -scattered duplicate members (re-acquisitions/re-exports of the same content, -acquired minutes/hours/days apart) across an entire multi-hour rebuild, -starving a bounded cache of any adjacency to exploit. Paging -``ORDER BY blob_hash, raw_id`` instead makes every duplicate group adjacent -in the SAME or next bounded pass, so a small cache reliably still holds the -first copy's entry when the second is processed. - -Three claims this file proves against the real production rebuild path -(``polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync`` -- -the SAME engine the offline CLI and the daemon bulk-rebuild route both -drive, not a reimplementation): - -1. **Grouping**: ``IndexGenerationStore.next_raw_page`` schedules a - byte-identical duplicate pair on the SAME bounded page even when their - ``acquired_at_ms`` values are interleaved with unrelated content between - them -- the exact scatter shape that starved the old acquisition-order - paging of any adjacency to exploit. -2. **Real dedup via the content cache, for never-before-classified raws**: - replaying a FRESH corpus (no prior classification, the case where paging - order is the only lever) through many small resumed passes -- each - threading the SAME persistent ``RawParsePrefetchCache``, mirroring the - daemon route -- costs exactly one real parse (spied at - ``revision_backfill._parse_retained_raw``, the actual per-representative - parse entry point) per DISTINCT content group, not one per raw. -3. **Batch-size invariance, WITH the cache threaded either way**: a single - large page does NOT by itself dedupe a never-before-classified duplicate - pair -- census selection still fragments them into separate - graph-singleton components regardless of page size (confirmed by - instrumentation before writing this test) -- so the large-batch run - threads its own cache too. With that, both the small-batch (many - resumed passes) and large-batch (one page) runs dedupe fully AND produce - byte-identical final archive content. -""" - -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path -from typing import Any - -import pytest - -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync -from polylogue.sources import revision_backfill -from polylogue.sources.revision_backfill import RawParsePrefetchCache -from polylogue.storage.index_generation import IndexGenerationStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt -from tests.infra.source_builders import admit_provider_source_packages, provider_source_package - - -def _codex_session(native_id: str, messages: tuple[tuple[str, str], ...]) -> bytes: - rows: list[dict[str, object]] = [ - {"type": "session_meta", "payload": {"id": native_id, "timestamp": "2026-07-27T00:00:00Z"}} - ] - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [ - { - "type": "input_text" if role == "user" else "output_text", - "text": text, - } - ], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _seed_duplicate_corpus(root: Path, *, group_count: int = 3) -> None: - """Write ``group_count`` distinct-content groups, each duplicated twice. - - Every raw is written fresh (no prior classification: no - ``raw_session_memberships`` row exists yet for any of them), so - ``ArchiveStore.raw_membership_selection_components`` cannot link a - duplicate pair through durable metadata -- each starts life as its own - graph-singleton census selection, exactly the order-sensitive case this - module documents. - - Acquisition order is interleaved ROUND-ROBIN across groups (group-0's - first copy, group-1's first copy, ..., group-0's second copy, ...) so - that under the OLD ``acquired_at_ms`` ordering a page of size 2 -- the - exact batch size these tests use -- would never contain both copies of - the same group; every duplicate pair was maximally scattered by - construction. Distinct source paths per copy mirror a real - re-acquisition/re-export (same bytes, different acquisition evidence). - """ - initialize_active_archive_root(root) - packages = [] - for copy_index in range(2): - for group_index in range(group_count): - path = root / "wire" / f"hord-group-{group_index}-copy-{copy_index}.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes( - _codex_session( - f"hord-group-{group_index}", - (("user", f"question {group_index}"), ("assistant", f"answer {group_index}")), - ) - ) - packages.append(provider_source_package("codex", (path,))) - result = admit_provider_source_packages(root, packages) - assert getattr(result, "parse_failures", 0) == 0 - - -def _canonical_snapshot(index_db: Path) -> dict[str, tuple[tuple[Any, ...], ...]]: - conn = sqlite3.connect(f"file:{index_db}?mode=ro", uri=True) - conn.row_factory = sqlite3.Row - try: - snapshot: dict[str, tuple[tuple[Any, ...], ...]] = {} - for table in ("sessions", "messages", "blocks"): - columns = tuple(row["name"] for row in conn.execute(f'PRAGMA table_xinfo("{table}")')) - quoted = ", ".join(f'"{column}"' for column in columns) - snapshot[table] = tuple( - sorted( - (tuple(row) for row in conn.execute(f'SELECT {quoted} FROM "{table}"')), - key=repr, - ) - ) - return snapshot - finally: - conn.close() - - -def _drive_rebuild_to_promotion( - root: Path, *, raw_batch_size: int, prefetch_cache: RawParsePrefetchCache | None = None -) -> list[Any]: - """Drive the real offline rebuild engine to promotion, resuming as needed. - - ``prefetch_cache``, when supplied, is the SAME instance passed to every - bounded pass -- mirroring ``daemon/bulk_rebuild.py``'s - ``run_daemon_bulk_rebuild_pass``, which threads one - ``DaemonParseStage.cache`` across every tick of a bulk rebuild rather - than minting a fresh one per pass. - """ - receipts: list[Any] = [] - operation_id: str | None = None - # The rebuild preflight requires a fresh schema-inference receipt; without - # one it refuses before any paging happens. - receipt_path = write_valid_rebuild_receipt(root, root.parent / f"{root.name}-schema-receipt.json") - for _ in range(20): # generous upper bound; promotion ends the loop early - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - raw_batch_size=raw_batch_size, - operation_id=operation_id, - prefetch_cache=prefetch_cache, - schema_inference_receipt_path=receipt_path, - ) - ) - receipts.append(receipt) - assert receipt.transaction is not None - operation_id = str(receipt.transaction["operation_id"]) - if receipt.transaction["status"] == "promoted": - return receipts - raise AssertionError("rebuild did not reach promotion within the iteration budget") - - -def test_next_raw_page_groups_duplicate_content_despite_scattered_acquisition_time(tmp_path: Path) -> None: - _seed_duplicate_corpus(tmp_path, group_count=3) - store = IndexGenerationStore.for_archive_root(tmp_path) - transaction = store.create_transaction(source_snapshot="snapshot") - - with sqlite3.connect(f"file:{tmp_path / 'source.db'}?mode=ro", uri=True) as conn: - hash_by_raw_id = { - str(raw_id): bytes(blob_hash).hex() - for raw_id, blob_hash in conn.execute("SELECT raw_id, blob_hash FROM raw_sessions") - } - assert len(hash_by_raw_id) == 6 - # Sanity check on the corpus shape: 3 distinct content groups, each - # duplicated exactly twice (2 raws share each of 3 distinct blob_hash - # values) -- and, by construction in ``_seed_duplicate_corpus``, the two - # copies of any one group were assigned maximally-scattered - # ``acquired_at_ms`` values (round-robin across groups), so an - # acquisition-time ordering would never place them on the same - # size-2 page. - assert len({hash_by_raw_id[raw_id] for raw_id in hash_by_raw_id}) == 3 - - page = store.next_raw_page(transaction, limit=2) - assert len(page.rows) == 2 - first_raw_id, first_hash_hex, _first_size = page.rows[0] - second_raw_id, second_hash_hex, _second_size = page.rows[1] - # The two rows scheduled together are a genuine duplicate pair: same - # content hash, distinct raw ids/source paths/acquired_at_ms. - assert first_hash_hex == second_hash_hex == hash_by_raw_id[first_raw_id] - assert first_raw_id != second_raw_id - - -def test_rebuild_content_order_paging_dedups_first_time_classification_via_content_cache( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The order-sensitive case: a corpus with NO prior classification. - - Confirmed by direct instrumentation before writing this test: on a - corpus that has already been classified once (duplicate pairs already - share a durable ``raw_session_memberships.logical_source_key``), - ``ArchiveStore.expand_raw_membership_selection`` pulls the whole linked - cohort into one ``_parse_retained_raws`` call regardless of page order, - so paging order makes no additional difference there. This test - isolates the case where it does: raws with no prior census at all. - """ - small_batch_root = tmp_path / "small-batch" - large_batch_root = tmp_path / "large-batch" - _seed_duplicate_corpus(small_batch_root, group_count=3) - _seed_duplicate_corpus(large_batch_root, group_count=3) - - # `write_raw_payload` records bytes without running admission, so every - # seeded raw keeps the default `quarantined` authority and the - # inactive-candidate gate refuses the corpus with "N raw(s) remain - # quarantined or undecided". Deriving authority from the bytes is the seam - # the other rebuild suites use; fabricating it with an UPDATE would be - # rejected later, because a rebuild re-derives byte authority for every - # frozen raw. - # - # It runs HERE, during seeding, for two reasons: it writes to source.db, so - # it must precede the schema-inference receipt that pins that snapshot; and - # the classifier parses raws itself, so running it after the spy below is - # installed would count those parses against the dedup assertion this test - # exists to make. - for _root in (small_batch_root, large_batch_root): - from polylogue.sources.revision_backfill import backfill_historical_revision_evidence - - backfill_historical_revision_evidence(_root, ingest_workers=1) - - parsed_raw_ids: list[str] = [] - real_parse = revision_backfill._parse_retained_raw - - def _spying_parse(archive: object, raw_id: str) -> object: - parsed_raw_ids.append(raw_id) - return real_parse(archive, raw_id) # type: ignore[arg-type] - - monkeypatch.setattr(revision_backfill, "_parse_retained_raw", _spying_parse) - - # ArchiveStore.open_owned_inactive_generation validates generation - # identity against the process-wide configured archive root, so each - # phase needs POLYLOGUE_ARCHIVE_ROOT pointed at ITS OWN root while it - # runs (mirrors test_bulk_rebuild.py's equivalence test). - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(small_batch_root)) - # Small batch (2 raws/page) forces 3 resumed passes over 6 never-before- - # classified raws. A persistent content cache threaded across all 3 - # passes (mirroring the daemon route) is what makes this order-sensitive: - # each pass's page is a genuine duplicate pair, so pass N's first raw - # parses and caches its content, and pass N's second raw (or, on a - # cache miss, a raw in the immediately following pass) hits the cache - # instead of reparsing. - small_cache = RawParsePrefetchCache(max_inflight_bytes=10_000_000, max_content_cache_bytes=10_000_000) - _drive_rebuild_to_promotion(small_batch_root, raw_batch_size=2, prefetch_cache=small_cache) - small_batch_parse_count = len(parsed_raw_ids) - parsed_raw_ids.clear() - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(large_batch_root)) - # Large batch: the whole corpus fits in one SCHEDULING page, but census - # selection still fragments unlinked duplicates into separate - # graph-singleton components (confirmed by instrumentation) processed - # one at a time within that single call -- so a page/batch large enough - # to cover the whole corpus is NOT, by itself, enough to dedupe a - # never-before-classified duplicate; the SAME threaded cache is what - # does it here too, just within one call instead of across several. - large_cache = RawParsePrefetchCache(max_inflight_bytes=10_000_000, max_content_cache_bytes=10_000_000) - _drive_rebuild_to_promotion(large_batch_root, raw_batch_size=100, prefetch_cache=large_cache) - large_batch_parse_count = len(parsed_raw_ids) - - # Content-order paging + the threaded cache dedup down to exactly one parse - # per distinct content group (3) even though every raw started completely - # unclassified -- NOT 6 (one per raw). - # - # polylogue-to76x (fixed): this was 6 because the rebuild's source-authority - # validation phase re-derives every selected source decision -- parsing each - # raw -- and was invoked without this pass's prefetch cache, so the replay - # phase immediately parsed the same content a second time. Both - # validate_frozen_source_authority call sites in - # maintenance/rebuild_index.py now receive request.prefetch_cache. - assert large_batch_parse_count == 3, ( - "within a single call, content-order paging plus the threaded cache must parse " - "each distinct content group exactly once" - ) - # The same cache now survives the resumed passes a small page forces, so a - # 2-raw page costs exactly the same parse work as a page covering the whole - # corpus. Measured 2026-08-18 before the fix: small=8, large=6 (8 exceeded - # the raw count of 6 outright). After: small=3, large=3. - # - # Asserting equality rather than `small >= large` on purpose: the weaker - # form is satisfied by any amount of redundant parsing on the resumed path, - # which is exactly the regression this test exists to catch. - assert small_batch_parse_count == 3, ( - "the threaded cache must survive resumed passes, so a small page parses " - "each distinct content group exactly once too" - ) - - small_snapshot = _canonical_snapshot(small_batch_root / "index.db") - large_snapshot = _canonical_snapshot(large_batch_root / "index.db") - # Acquisition paths and timestamps are intentionally distinct between the - # two independently admitted packages. Parsed message and block meaning - # must remain identical across page sizes. - assert small_snapshot["messages"] == large_snapshot["messages"] - assert small_snapshot["blocks"] == large_snapshot["blocks"] - assert len(small_snapshot["sessions"]) == 3 From 01041be2b370a5ca4f289b1bad8b88fafacbe171 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 04:13:06 +0200 Subject: [PATCH 02/11] chore: delete the generic repair product; raw convergence keeps one owner storage/repair.py's daemon-path closure (raw materialization conveyor, whale pass, backlog census, raw-authority frontier strategies) moves to storage/raw_convergence.py with RepairResult renamed RawConvergenceResult and repair_raw_materialization renamed converge_raw_materialization. The generic framework goes: REPAIR_HANDLERS, run_selected_maintenance, empty-session/superseded-snapshot/session-insight mutators, archive-debt previews and readiness vocabulary, maintenance planner/targets/replay/ failure_routing/envelope/registry/preview/cost_backfill/scope, the doctor --repair/--cleanup/--target/--preview/--vacuum flags, the maintenance plan/run/run-preview/preview/status CLI verbs, the daemon HTTP maintenance plan/run/status/operations routes, MCP maintenance preview/execute/status/list, the failures.jsonl status projection, and the blob-reference-closure mutation half. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- polylogue/archive/raw_authority_verdict.py | 2 +- polylogue/cli/commands/check.py | 12 +- polylogue/cli/commands/config.py | 2 +- .../cli/commands/maintenance/__init__.py | 18 +- .../maintenance/_blob_reference_closure.py | 63 - polylogue/cli/commands/maintenance/_plan.py | 112 -- .../cli/commands/maintenance/_preview.py | 86 - polylogue/cli/commands/maintenance/_run.py | 222 --- .../cli/commands/maintenance/_run_preview.py | 100 -- polylogue/cli/commands/maintenance/_status.py | 161 -- polylogue/cli/commands/status.py | 10 +- polylogue/cli/shared/check_maintenance.py | 52 - polylogue/cli/shared/check_models.py | 23 +- polylogue/cli/shared/check_options.py | 21 - polylogue/cli/shared/check_rendering_json.py | 8 - polylogue/cli/shared/check_rendering_plain.py | 48 +- polylogue/cli/shared/check_support.py | 26 - polylogue/cli/shared/check_validation.py | 22 - polylogue/cli/shared/check_workflow.py | 119 +- polylogue/config.py | 2 +- polylogue/daemon/cli.py | 6 +- polylogue/daemon/convergence_stages.py | 12 +- polylogue/daemon/health.py | 46 +- polylogue/daemon/http.py | 32 - polylogue/daemon/status.py | 150 +- polylogue/daemon/status_snapshot.py | 1 - polylogue/insights/archive.py | 13 - polylogue/insights/readiness.py | 3 +- polylogue/maintenance/__init__.py | 2 +- .../maintenance/blob_reference_closure.py | 542 +----- polylogue/maintenance/cost_backfill.py | 188 --- polylogue/maintenance/envelope.py | 231 --- polylogue/maintenance/failure_routing.py | 482 ------ polylogue/maintenance/invalidation.py | 14 +- polylogue/maintenance/models.py | 9 +- polylogue/maintenance/planner.py | 764 --------- polylogue/maintenance/preview.py | 441 ----- polylogue/maintenance/raw_authority.py | 26 +- polylogue/maintenance/registry.py | 352 ---- polylogue/maintenance/scope.py | 204 --- polylogue/maintenance/targets.py | 265 --- polylogue/mcp/declarations/registry.py | 10 +- polylogue/mcp/server_cutover.py | 155 +- polylogue/operations/__init__.py | 2 +- polylogue/operations/archive_debt.py | 23 +- polylogue/operations/daemon_workload_probe.py | 2 +- polylogue/pipeline/ids.py | 4 +- .../pipeline/services/parsing_workflow.py | 2 +- polylogue/readiness/__init__.py | 78 +- polylogue/readiness/capability.py | 20 - polylogue/sources/__init__.py | 2 +- polylogue/sources/census_parse_stage.py | 6 +- polylogue/sources/origin_specs.py | 2 +- polylogue/sources/revision_backfill.py | 6 +- polylogue/storage/insights/session/rebuild.py | 2 +- polylogue/storage/insights/session/runtime.py | 2 +- polylogue/storage/raw_authority.py | 8 +- .../storage/{repair.py => raw_convergence.py} | 1494 +---------------- polylogue/storage/raw_reconciler.py | 16 +- .../sqlite/archive_tiers/ingest_precedence.py | 2 +- .../sqlite/archive_tiers/source_write.py | 2 +- polylogue/storage/sqlite/lifecycle.py | 11 +- 62 files changed, 150 insertions(+), 6591 deletions(-) delete mode 100644 polylogue/cli/commands/maintenance/_blob_reference_closure.py delete mode 100644 polylogue/cli/commands/maintenance/_plan.py delete mode 100644 polylogue/cli/commands/maintenance/_preview.py delete mode 100644 polylogue/cli/commands/maintenance/_run.py delete mode 100644 polylogue/cli/commands/maintenance/_run_preview.py delete mode 100644 polylogue/cli/commands/maintenance/_status.py delete mode 100644 polylogue/cli/shared/check_maintenance.py delete mode 100644 polylogue/maintenance/cost_backfill.py delete mode 100644 polylogue/maintenance/envelope.py delete mode 100644 polylogue/maintenance/failure_routing.py delete mode 100644 polylogue/maintenance/planner.py delete mode 100644 polylogue/maintenance/preview.py delete mode 100644 polylogue/maintenance/registry.py delete mode 100644 polylogue/maintenance/scope.py delete mode 100644 polylogue/maintenance/targets.py rename polylogue/storage/{repair.py => raw_convergence.py} (82%) diff --git a/polylogue/archive/raw_authority_verdict.py b/polylogue/archive/raw_authority_verdict.py index a4d62e8280..cc0bb1e0fc 100644 --- a/polylogue/archive/raw_authority_verdict.py +++ b/polylogue/archive/raw_authority_verdict.py @@ -12,7 +12,7 @@ ``raw_authority_censuses``, ``raw_authority_census_plans``, ``raw_authority_post_plans``, ``raw_authority_parser_census``, and ``raw_membership_census``, or removing/rewriting the ~5,000-8,000 lines of -classification machinery in ``polylogue/storage/repair.py`` and +classification machinery in ``polylogue/storage/raw_convergence.py`` and ``polylogue/storage/sqlite/archive_tiers/revision_governance.py`` that produce that evidence. Those tables and modules remain the system of record this phase; this module only reads their *output* (via the diff --git a/polylogue/cli/commands/check.py b/polylogue/cli/commands/check.py index 3728164c83..9eab1257b4 100644 --- a/polylogue/cli/commands/check.py +++ b/polylogue/cli/commands/check.py @@ -18,11 +18,6 @@ def check_command( env: AppEnv, output_format: str | None, verbose: bool, - repair: bool, - cleanup: bool, - maintenance_targets: tuple[str, ...], - preview: bool, - vacuum: bool, deep: bool, runtime: bool, check_daemon: bool, @@ -43,15 +38,10 @@ def check_command( schema_record_offset: int, schema_quarantine_malformed: bool, ) -> None: - """Health check with optional maintenance and cleanup previews.""" + """Read-only health check over the archive, runtime, daemon, blobs, and schemas.""" options = CheckCommandOptions( json_output=output_format == "json", verbose=verbose, - repair=repair, - cleanup=cleanup, - maintenance_targets=maintenance_targets, - preview=preview, - vacuum=vacuum, deep=deep, runtime=runtime, check_daemon=check_daemon, diff --git a/polylogue/cli/commands/config.py b/polylogue/cli/commands/config.py index 34a20dd67e..8e630d2a24 100644 --- a/polylogue/cli/commands/config.py +++ b/polylogue/cli/commands/config.py @@ -35,7 +35,7 @@ def config_command(ctx: click.Context, output_format: str, show_layers: bool) -> # Deferred: `completions.py` transitively imports the whole insights/storage # stack (polylogue.operations.action_contracts -> operations.archive -> -# insights.archive -> storage.repair), ~650ms alone. `config --help` only +# insights.archive -> storage.raw_convergence), ~650ms alone. `config --help` only # needs each subcommand's name + short help, not its implementation, so these # register as lazy proxies (matches the root-level pattern in # click_command_registration.py) instead of importing eagerly at module scope. diff --git a/polylogue/cli/commands/maintenance/__init__.py b/polylogue/cli/commands/maintenance/__init__.py index 4f6887abda..d377814667 100644 --- a/polylogue/cli/commands/maintenance/__init__.py +++ b/polylogue/cli/commands/maintenance/__init__.py @@ -1,4 +1,4 @@ -"""Maintenance command group: preview and run backfills. +"""Maintenance command group: archive inspection and guarded recovery verbs. Each subcommand lives in its own submodule and is attached lazily (the ``_LazyCommand`` pattern already used for the root CLI's own dispatch, @@ -25,7 +25,6 @@ "beads_origin_census_command", "Read-only census and exact plan for retired Beads-origin evidence.", ), - ("plan", "_plan", "plan_command", "Dry-run summary: show what would be rebuilt without executing."), ("archive-plan", "_archive_plan", "archive_plan_command", "Inspect readiness for the archive file set."), ( "backup-plan", @@ -64,13 +63,6 @@ "source_continuity_recovery_command", "Recover one authenticated pre-#3868 source liveness transition offline.", ), - ( - "run-preview", - "_run_preview", - "run_preview_command", - "Preview a maintenance run (dry, resumable) without executing. Read-only.", - ), - ("run", "_run", "run_command", "Execute maintenance backfill operations."), ( "raw-authority-frontier", "_raw_identity", @@ -113,7 +105,6 @@ "operation_recovery_command", "Inspect or adjudicate bounded interrupted-operation recovery evidence.", ), - ("preview", "_preview", "preview_command", "Staleness inventory by model and scope. Read-only."), ("blob-gc", "_blob_gc", "blob_gc_command", "Preview lease-safe blob garbage collection. Read-only."), ( "blob-publications", @@ -139,12 +130,6 @@ "blob_conservation_command", "Verify both directions of blob/reference conservation without mutation.", ), - ( - "blob-reference-closure", - "_blob_reference_closure", - "blob_reference_closure_command", - "Repair deterministic raw and acquired-attachment reference gaps; dry-run by default.", - ), ( "hook-payload-ref-reconcile", "_hook_payload_ref_reconciliation", @@ -200,7 +185,6 @@ "gc_recover_command", "Inspect or explicitly abandon a blocked pending blob-GC generation without unlinking blobs.", ), - ("status", "_status", "status_command", "Inspect persisted maintenance operations (#1197)."), ( "verify-archive", "_verify_archive", diff --git a/polylogue/cli/commands/maintenance/_blob_reference_closure.py b/polylogue/cli/commands/maintenance/_blob_reference_closure.py deleted file mode 100644 index 71593416e2..0000000000 --- a/polylogue/cli/commands/maintenance/_blob_reference_closure.py +++ /dev/null @@ -1,63 +0,0 @@ -"""CLI adapter for acquired blob-reference closure repair.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import click - -from polylogue.paths import archive_root - - -@click.command("blob-reference-closure") -@click.option("--apply", "apply_changes", is_flag=True, help="Apply only deterministic exact reference repairs.") -@click.option( - "--backup-manifest", - type=click.Path(dir_okay=False, path_type=Path), - default=None, - help="Verified manifest covering source.db and index.db; required with --apply.", -) -@click.option( - "--receipt-file", - type=click.Path(dir_okay=False, path_type=Path), - default=None, - help="New immutable receipt path; required with --apply.", -) -@click.option("--output-format", type=click.Choice(["plain", "json"]), default="plain", show_default=True) -def blob_reference_closure_command( - apply_changes: bool, - backup_manifest: Path | None, - receipt_file: Path | None, - output_format: str, -) -> None: - """Audit closure, or repair exact refs from existing source evidence.""" - from polylogue.maintenance.blob_reference_closure import ( - BlobReferenceClosureError, - reconcile_blob_reference_closure, - ) - - try: - report = reconcile_blob_reference_closure( - archive_root(), - backup_manifest=backup_manifest, - receipt_path=receipt_file, - dry_run=not apply_changes, - ) - except BlobReferenceClosureError as exc: - raise click.ClickException(str(exc)) from exc - - payload = {"mode": "blob_reference_closure", **report.to_dict()} - if output_format == "json": - click.echo(json.dumps(payload, indent=2, sort_keys=True)) - return - click.echo("Blob-reference closure") - click.echo(f"Mode: {'apply' if report.applied else 'dry-run'}") - click.echo(f"Raw repair: {report.raw_repaired_count:,}") - click.echo(f"Attachment: {report.attachment_repaired_count:,}") - click.echo(f"Blockers: {len(report.plan.blockers):,}") - for blocker in report.plan.blockers: - click.echo(f" blocker [{blocker.kind.value}] {blocker.object_id}: {blocker.detail}") - - -__all__ = ["blob_reference_closure_command"] diff --git a/polylogue/cli/commands/maintenance/_plan.py b/polylogue/cli/commands/maintenance/_plan.py deleted file mode 100644 index 33be71018e..0000000000 --- a/polylogue/cli/commands/maintenance/_plan.py +++ /dev/null @@ -1,112 +0,0 @@ -"""``maintenance plan``: dry-run summary of what a backfill would touch.""" - -from __future__ import annotations - -import json - -import click - -from polylogue.cli.commands.maintenance._shared import _apply_scope_filter_options -from polylogue.cli.shared.types import AppEnv -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.logging import configure_logging -from polylogue.maintenance.envelope import envelope_from_operation -from polylogue.maintenance.planner import preview_backfill -from polylogue.maintenance.scope import MaintenanceScopeFilter -from polylogue.maintenance.targets import MAINTENANCE_TARGET_NAMES, build_maintenance_target_catalog -from polylogue.paths import archive_root, render_root - -_MAINTENANCE_TARGET_HELP = build_maintenance_target_catalog().help_text() - - -@click.command("plan") -@click.option( - "--target", - "targets", - multiple=True, - type=click.Choice(MAINTENANCE_TARGET_NAMES), - help=_MAINTENANCE_TARGET_HELP, -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format. ``json`` emits the shared MaintenanceOperationEnvelope.", -) -@_apply_scope_filter_options -@click.pass_obj -def plan_command( - env: AppEnv, - targets: tuple[str, ...], - output_format: str, - session_ids: tuple[str, ...], - origin: str | None, - source_family: str | None, - source_root: str | None, - since: str | None, - until: str | None, - failure_kind: str | None, - parser_version: str | None, -) -> None: - """Dry-run summary: show what would be rebuilt without executing. - - Displays affected rows and estimated time for each target. - Read-only — no mutations are performed. - """ - configure_logging() - config = Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], # maintenance doesn't need source acquisition - ) - try: - scope_filter = MaintenanceScopeFilter.from_surface_args( - session_ids=session_ids, - origin=origin, - source_family=source_family, - source_root=source_root, - since=since, - until=until, - failure_kind=failure_kind, - parser_version=parser_version, - ) - except ValueError as exc: - raise click.UsageError(str(exc)) from exc - result = preview_backfill(config, targets=targets, scope_filter=scope_filter) - - if output_format == "json": - envelope = envelope_from_operation(result, origin="cli", mode="preview") - click.echo(json.dumps(envelope.to_dict(), indent=2, sort_keys=True)) - if result.status is OperationStatus.FAILED: - raise SystemExit(1) - return - - click.echo(f"Operation: {result.operation_id}") - click.echo(f"Targets: {', '.join(result.targets) if result.targets else 'all'}") - click.echo(f"Affected: {result.affected_rows:,} rows") - if result.estimated_time_s > 0: - click.echo(f"Estimate: ~{result.estimated_time_s:.1f}s") - - if result.results: - click.echo("\nPer-target preview:") - for r in result.results: - name = r.get("name", "unknown") - issue_count = r.get("issue_count", 0) - healthy = r.get("healthy", True) - detail = r.get("detail", "") - status_str = "OK" if healthy else f"{issue_count:,} issues" - click.echo(f" {name}: {status_str}") - if detail and not healthy: - click.echo(f" {detail}") - - if result.error: - click.echo(f"\nError: {result.error}", err=True) - for sample in result.failure_samples.samples: - click.echo(f"Refused: [{sample.kind}] {sample.locator}: {sample.message}", err=True) - if result.status is OperationStatus.FAILED: - # A refused plan is not a plan. Exit nonzero so a scripted caller - # cannot read "Affected: 0 rows" as a clean archive. - raise SystemExit(1) diff --git a/polylogue/cli/commands/maintenance/_preview.py b/polylogue/cli/commands/maintenance/_preview.py deleted file mode 100644 index bc1db68e48..0000000000 --- a/polylogue/cli/commands/maintenance/_preview.py +++ /dev/null @@ -1,86 +0,0 @@ -"""``maintenance preview``: read-only staleness inventory by model and scope.""" - -from __future__ import annotations - -import json - -import click - -from polylogue.cli.shared.types import AppEnv -from polylogue.logging import configure_logging - -# Mirrors polylogue.maintenance.preview.ALL_SCOPES. Hardcoded (not imported) -# so this decorator's choices don't force polylogue.maintenance.preview -- -# and its storage.derived/storage.repair import chain -- onto the `--help` -# path; test_preview_scopes_match_preview_module asserts these stay in sync. -# See polylogue-sod7. -_ALL_SCOPES = ("derived", "retrieval", "archive_cleanup") - - -@click.command("preview") -@click.option( - "--scope", - "scopes", - multiple=True, - type=click.Choice(_ALL_SCOPES), - help="Limit preview to named scopes (derived, retrieval, archive_cleanup).", -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format.", -) -@click.option( - "--shallow", - is_flag=True, - help="Skip the expensive full-verification path (faster, slightly less accurate).", -) -@click.pass_obj -def preview_command( - env: AppEnv, - scopes: tuple[str, ...], - output_format: str, - shallow: bool, -) -> None: - """Staleness inventory by model and scope. Read-only. - - Shows per-model counts of stale/missing/orphan rows with typed - :class:`InvalidationReason` tags. Use before triggering ``polylogue - maintenance run`` so the operator knows what will be rebuilt and why. - Models with nothing stale produce explicit zero rows rather than - being absent from the output. - """ - from polylogue.maintenance.preview import staleness_inventory - - configure_logging() - inventory = staleness_inventory( - scopes=scopes or None, - verify_full=not shallow, - ) - - if output_format == "json": - click.echo(json.dumps(inventory.to_dict(), indent=2, sort_keys=True)) - return - - click.echo(f"Captured: {inventory.captured_at}") - click.echo(f"Database: {inventory.db_path}") - click.echo(f"Scopes: {', '.join(inventory.scopes)}") - click.echo(f"Total stale rows: {inventory.total_stale():,}") - click.echo("") - - by_model = inventory.by_model() - if not by_model: - click.echo("No models inventoried.") - return - - for model, items in sorted(by_model.items()): - click.echo(f"{model}:") - for item in items: - fraction_pct = item.fraction * 100.0 - click.echo( - f" {item.reason.value:>20s} count={item.count:>10,} fraction={fraction_pct:>5.1f}% {item.detail}" - ) - click.echo("") diff --git a/polylogue/cli/commands/maintenance/_run.py b/polylogue/cli/commands/maintenance/_run.py deleted file mode 100644 index e05501673f..0000000000 --- a/polylogue/cli/commands/maintenance/_run.py +++ /dev/null @@ -1,222 +0,0 @@ -"""``maintenance run``: execute maintenance backfill operations. - -The read-only twin of this command is ``maintenance run-preview`` -(:mod:`polylogue.cli.commands.maintenance._run_preview`) -- same target -catalog, resume, and scope-filter surface, but the command type itself is -non-mutating (no ``--dry-run``/``--apply`` flag to remember). Both commands -share :func:`_execute_and_render`, which threads ``dry_run`` straight into -:func:`polylogue.maintenance.replay.execute_replay`; the underlying replay -semantics are byte-identical to the previous fused ``run --dry-run`` mode. -""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -import click - -from polylogue.cli.commands.maintenance._shared import _apply_scope_filter_options -from polylogue.cli.shared.types import AppEnv -from polylogue.config import Config -from polylogue.logging import configure_logging -from polylogue.maintenance.scope import MaintenanceScopeFilter -from polylogue.maintenance.targets import MAINTENANCE_TARGET_NAMES, build_maintenance_target_catalog -from polylogue.paths import archive_root, render_root - -if TYPE_CHECKING: - from polylogue.maintenance.replay import ReplayProgress - -_MAINTENANCE_TARGET_HELP = build_maintenance_target_catalog().help_text() - - -@click.command("run") -@click.option( - "--target", - "targets", - multiple=True, - type=click.Choice(MAINTENANCE_TARGET_NAMES), - help=_MAINTENANCE_TARGET_HELP, -) -@click.option( - "--operation-id", - "operation_id", - type=str, - default=None, - help=("Reuse a previous operation id to resume an interrupted run; omit to mint a fresh uuid for a new operation."), -) -@click.option( - "--resume", - "resume_cursor", - type=str, - default=None, - help=( - "Explicit resume cursor (e.g. 'target:2'). When omitted and " - "--operation-id matches a persisted state file, the cursor is " - "loaded automatically." - ), -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format. ``json`` emits the shared MaintenanceOperationEnvelope.", -) -@_apply_scope_filter_options -@click.pass_obj -def run_command( - env: AppEnv, - targets: tuple[str, ...], - operation_id: str | None, - resume_cursor: str | None, - output_format: str, - session_ids: tuple[str, ...], - origin: str | None, - source_family: str | None, - source_root: str | None, - since: str | None, - until: str | None, - failure_kind: str | None, - parser_version: str | None, -) -> None: - """Execute maintenance backfill operations. - - Executes targeted rebuilds using existing repair infrastructure. - Per-target failures are isolated: one failing target does not abort - the remaining work. Use --operation-id together with --resume to - pick up an interrupted operation from its last checkpoint. For a - read-only dry run of the same replay path, use ``run-preview``. - """ - _execute_and_render( - env=env, - targets=targets, - dry_run=False, - operation_id=operation_id, - resume_cursor=resume_cursor, - output_format=output_format, - session_ids=session_ids, - origin=origin, - source_family=source_family, - source_root=source_root, - since=since, - until=until, - failure_kind=failure_kind, - parser_version=parser_version, - ) - - -def _execute_and_render( - *, - env: AppEnv, - targets: tuple[str, ...], - dry_run: bool, - operation_id: str | None, - resume_cursor: str | None, - output_format: str, - session_ids: tuple[str, ...], - origin: str | None, - source_family: str | None, - source_root: str | None, - since: str | None, - until: str | None, - failure_kind: str | None, - parser_version: str | None, -) -> None: - """Shared body for ``run`` and ``run-preview``: execute_replay + render. - - ``dry_run`` is fixed per caller (``False`` for ``run``, ``True`` for - ``run-preview``) rather than exposed as a CLI flag -- the command name - is the read/write signal, not an option an operator can forget. - """ - from polylogue.core.enums import OperationStatus - from polylogue.maintenance.envelope import envelope_from_operation - from polylogue.maintenance.replay import execute_replay - - configure_logging() - config = Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], - ) - - def _emit_progress(snapshot: ReplayProgress) -> None: - detail = f" {snapshot.progress_desc}" if snapshot.progress_desc else "" - amount = f" amount={snapshot.progress_amount}" if snapshot.progress_amount is not None else "" - click.echo( - f" [{snapshot.processed}/{snapshot.total}] {snapshot.target} " - f"cursor={snapshot.cursor} failures={snapshot.in_flight_failures}{amount}{detail}", - err=True, - ) - - try: - scope_filter = MaintenanceScopeFilter.from_surface_args( - session_ids=session_ids, - origin=origin, - source_family=source_family, - source_root=source_root, - since=since, - until=until, - failure_kind=failure_kind, - parser_version=parser_version, - ) - except ValueError as exc: - raise click.UsageError(str(exc)) from exc - result = execute_replay( - config, - targets=targets, - operation_id=operation_id, - resume_cursor=resume_cursor, - dry_run=dry_run, - progress_callback=_emit_progress, - scope_filter=scope_filter, - ) - - if output_format == "json": - envelope = envelope_from_operation(result, origin="cli", mode="execute") - click.echo(json.dumps(envelope.to_dict(), indent=2, sort_keys=True)) - if result.status is OperationStatus.FAILED: - raise SystemExit(1) - return - - action = "Would affect" if dry_run else "Processed" - click.echo(f"Operation: {result.operation_id}") - click.echo(f"Targets: {', '.join(result.targets) if result.targets else 'all'}") - click.echo(f"Status: {result.status.value}") - click.echo(f"Cursor: {result.resume_cursor}") - click.echo(f"{action}: {result.affected_rows:,} rows") - - if result.results: - click.echo(f"\n{'Would-be' if dry_run else ''} Results:") - for r in result.results: - name = r.get("name", "unknown") - success = r.get("success", False) - repaired = r.get("repaired_count", 0) - detail = r.get("detail", "") - status_icon = "OK" if success else "FAILED" - click.echo(f" {name}: {status_icon} ({repaired} items)") - if detail: - click.echo(f" {detail}") - - if result.error: - click.echo(f"\nError: {result.error}", err=True) - - if result.failure_samples.samples: - click.echo("\nFailures:", err=True) - for sample in result.failure_samples.samples: - click.echo(f" {sample.kind} @ {sample.locator}: {sample.message}", err=True) - if result.failure_samples.truncated: - click.echo(" (failure samples truncated)", err=True) - - if result.completed_at: - from datetime import datetime - - if result.started_at: - started = datetime.fromisoformat(result.started_at) - completed = datetime.fromisoformat(result.completed_at) - elapsed = (completed - started).total_seconds() - click.echo(f"\nElapsed: {elapsed:.1f}s") - - if result.status is OperationStatus.FAILED: - raise SystemExit(1) diff --git a/polylogue/cli/commands/maintenance/_run_preview.py b/polylogue/cli/commands/maintenance/_run_preview.py deleted file mode 100644 index 503af40c14..0000000000 --- a/polylogue/cli/commands/maintenance/_run_preview.py +++ /dev/null @@ -1,100 +0,0 @@ -"""``maintenance run-preview``: read-only dry run of a maintenance replay. - -Read-only twin of ``maintenance run`` (:mod:`polylogue.cli.commands.maintenance._run`). -Shares the same target catalog, resume/operation-id, and scope-filter surface, -and calls the identical :func:`polylogue.maintenance.replay.execute_replay` -machinery -- resumable per-target checkpointing included -- with ``dry_run`` -fixed to ``True`` instead of exposed as a flag. This command never mutates the -archive; it exercises the same repair-simulation code path each target's -``execute`` step would take, which is why it is a distinct verb rather than a -lighter estimate like ``maintenance plan``. -""" - -from __future__ import annotations - -import click - -from polylogue.cli.commands.maintenance._run import _execute_and_render -from polylogue.cli.commands.maintenance._shared import _apply_scope_filter_options -from polylogue.cli.shared.types import AppEnv -from polylogue.maintenance.targets import MAINTENANCE_TARGET_NAMES, build_maintenance_target_catalog - -_MAINTENANCE_TARGET_HELP = build_maintenance_target_catalog().help_text() - - -@click.command("run-preview") -@click.option( - "--target", - "targets", - multiple=True, - type=click.Choice(MAINTENANCE_TARGET_NAMES), - help=_MAINTENANCE_TARGET_HELP, -) -@click.option( - "--operation-id", - "operation_id", - type=str, - default=None, - help=( - "Reuse a previous operation id to resume an interrupted preview; omit to mint a fresh uuid for a new operation." - ), -) -@click.option( - "--resume", - "resume_cursor", - type=str, - default=None, - help=( - "Explicit resume cursor (e.g. 'target:2'). When omitted and " - "--operation-id matches a persisted state file, the cursor is " - "loaded automatically." - ), -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format. ``json`` emits the shared MaintenanceOperationEnvelope.", -) -@_apply_scope_filter_options -@click.pass_obj -def run_preview_command( - env: AppEnv, - targets: tuple[str, ...], - operation_id: str | None, - resume_cursor: str | None, - output_format: str, - session_ids: tuple[str, ...], - origin: str | None, - source_family: str | None, - source_root: str | None, - since: str | None, - until: str | None, - failure_kind: str | None, - parser_version: str | None, -) -> None: - """Preview maintenance backfill operations without executing. Read-only. - - Runs the same resumable replay path as ``run`` -- including per-target - repair simulation and checkpoint tracking -- but never mutates the - archive. Use --operation-id together with --resume to pick up an - interrupted preview from its last checkpoint. - """ - _execute_and_render( - env=env, - targets=targets, - dry_run=True, - operation_id=operation_id, - resume_cursor=resume_cursor, - output_format=output_format, - session_ids=session_ids, - origin=origin, - source_family=source_family, - source_root=source_root, - since=since, - until=until, - failure_kind=failure_kind, - parser_version=parser_version, - ) diff --git a/polylogue/cli/commands/maintenance/_status.py b/polylogue/cli/commands/maintenance/_status.py deleted file mode 100644 index af76789776..0000000000 --- a/polylogue/cli/commands/maintenance/_status.py +++ /dev/null @@ -1,161 +0,0 @@ -"""``maintenance status``: inspect persisted maintenance operations (#1197).""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -import click - -from polylogue.cli.shared.types import AppEnv -from polylogue.config import Config -from polylogue.logging import configure_logging -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.paths import archive_root, render_root - -if TYPE_CHECKING: - from polylogue.maintenance.registry import OperationRecord - - -@click.command("status") -@click.option( - "--operation-id", - "operation_id", - type=str, - default=None, - help="Show one operation by id. Omit to list all in-flight and recent operations.", -) -@click.option( - "--all", - "show_all", - is_flag=True, - help="Include completed operations in the listing (default: only running / failed).", -) -@click.option( - "--output-format", - "output_format", - type=click.Choice(["plain", "json"]), - default="plain", - show_default=True, - help="Output format. ``json`` emits the shared MaintenanceOperationEnvelope per record.", -) -@click.pass_obj -def status_command( - env: AppEnv, - operation_id: str | None, - show_all: bool, - output_format: str, -) -> None: - """Inspect persisted maintenance operations (#1197). - - Without ``--operation-id``, lists every persisted operation under - ``/.maintenance-state/``. By default the listing hides - completed operations to surface only in-flight or failed work; pass - ``--all`` to include them. - - With ``--operation-id``, tails one operation: emits the same shared - :class:`~polylogue.maintenance.envelope.MaintenanceOperationEnvelope` - that the CLI ``plan``/``run`` commands, daemon HTTP, and MCP tools - return. - """ - if operation_id is not None: - try: - operation_id = validate_operation_id(operation_id) - except ValueError as exc: - raise click.BadParameter(str(exc), param_hint="--operation-id") from exc - - from polylogue.maintenance.envelope import envelope_from_operation - from polylogue.maintenance.registry import MaintenanceOperationRegistry - - configure_logging() - config = Config( - archive_root=archive_root(), - render_root=render_root(), - sources=[], - ) - registry = MaintenanceOperationRegistry(config=config) - - if operation_id is not None: - record = registry.get_operation(operation_id) - if record is None: - if output_format == "json": - click.echo(json.dumps({"error": "not_found", "operation_id": operation_id})) - else: - click.echo(f"No persisted operation with id {operation_id!r}.", err=True) - raise click.exceptions.Exit(code=1) - envelope = envelope_from_operation(record.operation, origin="cli", mode="execute") - if output_format == "json": - single_payload: dict[str, object] = { - "envelope": envelope.to_dict(), - "updated_at": record.updated_at, - "state_path": str(record.state_path), - } - click.echo(json.dumps(single_payload, indent=2, sort_keys=True)) - return - _render_record_plain(record) - return - - records = registry.list_operations() - if not show_all: - records = tuple(r for r in records if r.status.value != "completed") - - if output_format == "json": - list_payload: dict[str, object] = { - "operations": [ - { - "envelope": envelope_from_operation(r.operation, origin="cli", mode="execute").to_dict(), - "updated_at": r.updated_at, - "state_path": str(r.state_path), - } - for r in records - ], - "total": len(records), - } - click.echo(json.dumps(list_payload, indent=2, sort_keys=True)) - return - - if not records: - click.echo("No persisted maintenance operations.") - return - click.echo(f"Persisted maintenance operations ({len(records)} total, newest first):") - click.echo("") - for record in records: - targets = ", ".join(record.operation.targets) if record.operation.targets else "all" - click.echo( - f" {record.operation_id} status={record.status.value:>9s} " - f"updated_at={record.updated_at} targets={targets}" - ) - if record.operation.resume_cursor: - click.echo(f" cursor={record.operation.resume_cursor}") - if record.operation.failure_samples.samples: - n = len(record.operation.failure_samples.samples) - click.echo(f" failures={n}") - - -def _render_record_plain(record: OperationRecord) -> None: - """Render one operation record in human-readable form.""" - op = record.operation - click.echo(f"Operation: {op.operation_id}") - click.echo(f"Status: {op.status.value}") - click.echo(f"Updated: {record.updated_at}") - click.echo(f"Targets: {', '.join(op.targets) if op.targets else 'all'}") - click.echo(f"Progress: {op.progress * 100.0:.1f}%") - click.echo(f"Affected: {op.affected_rows:,} rows") - if op.resume_cursor: - click.echo(f"Cursor: {op.resume_cursor}") - if op.started_at: - click.echo(f"Started: {op.started_at}") - if op.completed_at: - click.echo(f"Completed: {op.completed_at}") - if op.error: - click.echo(f"Error: {op.error}", err=True) - if op.failure_samples.samples: - click.echo("Failures:", err=True) - for sample in op.failure_samples.samples: - click.echo(f" {sample.kind} @ {sample.locator}: {sample.message}", err=True) - if op.failure_samples.truncated: - click.echo(" (failure samples truncated)", err=True) - click.echo(f"State file: {record.state_path}") - - -__all__ = ["status_command"] diff --git a/polylogue/cli/commands/status.py b/polylogue/cli/commands/status.py index 29f0a44d48..5f018989e8 100644 --- a/polylogue/cli/commands/status.py +++ b/polylogue/cli/commands/status.py @@ -797,7 +797,7 @@ def _raw_replay_backlog_status(active_root: Path, *, limit: int = 5) -> dict[str try: from polylogue.config import Config from polylogue.paths import render_root - from polylogue.storage.repair import raw_materialization_replay_backlog + from polylogue.storage.raw_convergence import raw_materialization_replay_backlog return raw_materialization_replay_backlog( Config(archive_root=active_root, render_root=render_root(), sources=[]), @@ -1396,7 +1396,6 @@ def _compact_raw_failure_status(status: dict[str, Any]) -> dict[str, Any]: "parse": "raw_parse_failures", "validation": "raw_validation_failures", "quarantined": "raw_quarantined", - "maintenance": "raw_maintenance_failures", "deferred_retryable": "raw_deferred_failures", "terminal_rejections": "raw_terminal_rejections", "unexplained": "raw_unexplained_failures", @@ -1432,7 +1431,6 @@ def _direct_raw_failure_status(root: Path) -> dict[str, Any]: "raw_parse_failures": _safe_int(info.get("parse_failures")), "raw_validation_failures": _safe_int(info.get("validation_failures")), "raw_quarantined": _safe_int(info.get("quarantined")), - "raw_maintenance_failures": _safe_int(info.get("maintenance_failures")), "raw_deferred_failures": _safe_int(info.get("deferred_failures")), "raw_terminal_rejections": _safe_int(info.get("terminal_rejections")), "raw_unexplained_failures": _safe_int(info.get("unexplained_failures")), @@ -2259,11 +2257,7 @@ def _show_direct_status( env.ui.console.print(f" Raw records: {raw:,}") raw_failure_status = _direct_raw_failure_status(root) raw_lifecycle_healthy = _raw_failure_lifecycle_is_healthy(raw_failure_status) - raw_total = ( - raw_failure_status["raw_parse_failures"] - + raw_failure_status["raw_validation_failures"] - + raw_failure_status["raw_maintenance_failures"] - ) + raw_total = raw_failure_status["raw_parse_failures"] + raw_failure_status["raw_validation_failures"] if raw_total: env.ui.console.print( " Raw failures: " diff --git a/polylogue/cli/shared/check_maintenance.py b/polylogue/cli/shared/check_maintenance.py deleted file mode 100644 index 046f336320..0000000000 --- a/polylogue/cli/shared/check_maintenance.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Maintenance-target selection and preview-count helpers for check workflow.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from polylogue.cli.shared.check_models import VacuumResult -from polylogue.cli.shared.types import AppEnv -from polylogue.maintenance.targets import MaintenanceTargetMode, build_maintenance_target_catalog -from polylogue.readiness import ReadinessReport -from polylogue.storage.repair import RepairResult, preview_counts_from_archive_debt - -if TYPE_CHECKING: - from polylogue.cli.shared.check_workflow import CheckCommandOptions - - -def build_preview_counts(report: ReadinessReport) -> dict[str, int]: - return preview_counts_from_archive_debt(report.archive_debt) - - -def resolve_selected_maintenance_targets( - options: CheckCommandOptions, -) -> tuple[str, ...]: - if options.maintenance_targets: - return tuple(options.maintenance_targets) - catalog = build_maintenance_target_catalog() - targets: list[str] = [] - if options.repair: - targets.extend(catalog.names_for_mode(MaintenanceTargetMode.REPAIR)) - if options.cleanup: - targets.extend(catalog.names_for_mode(MaintenanceTargetMode.CLEANUP)) - return tuple(targets) - - -def persist_maintenance_run( - env: AppEnv, - *, - report: ReadinessReport, - options: CheckCommandOptions, - targets: tuple[str, ...], - maintenance_results: list[RepairResult], - vacuum_result: VacuumResult | None, - preview_counts: dict[str, int] | None, -) -> None: - """No-op — maintenance run recording removed.""" - - -__all__ = [ - "build_preview_counts", - "persist_maintenance_run", - "resolve_selected_maintenance_targets", -] diff --git a/polylogue/cli/shared/check_models.py b/polylogue/cli/shared/check_models.py index 06f846722f..87c93ef33c 100644 --- a/polylogue/cli/shared/check_models.py +++ b/polylogue/cli/shared/check_models.py @@ -4,29 +4,13 @@ from dataclasses import dataclass -from polylogue.core.json import JSONDocument, json_document +from polylogue.core.json import JSONDocument from polylogue.readiness import ReadinessReport from polylogue.schemas.validation.models import ArtifactCoverageReport, SchemaVerificationReport from polylogue.storage.artifacts.views import ArtifactCohortSummary -from polylogue.storage.repair import RepairResult from polylogue.storage.runtime import ArtifactObservationRecord -@dataclass(frozen=True) -class VacuumResult: - """Machine-safe VACUUM result payload shared by workflow and renderers.""" - - ok: bool - detail: str - preview: bool = False - - def to_dict(self) -> JSONDocument: - payload: dict[str, str | bool] = {"ok": self.ok, "detail": self.detail} - if self.preview: - payload["preview"] = True - return json_document(payload) - - @dataclass class CheckCommandResult: """Typed output surface for the check command workflow.""" @@ -39,9 +23,6 @@ class CheckCommandResult: artifact_rows: list[ArtifactObservationRecord] | None = None cohort_rows: list[ArtifactCohortSummary] | None = None blob_report: JSONDocument | None = None - maintenance_results: list[RepairResult] | None = None - maintenance_targets: tuple[str, ...] = () - vacuum_result: VacuumResult | None = None -__all__ = ["CheckCommandResult", "VacuumResult"] +__all__ = ["CheckCommandResult"] diff --git a/polylogue/cli/shared/check_options.py b/polylogue/cli/shared/check_options.py index 390cee7ac4..a344a1e44c 100644 --- a/polylogue/cli/shared/check_options.py +++ b/polylogue/cli/shared/check_options.py @@ -6,33 +6,12 @@ import click -from polylogue.maintenance.targets import MAINTENANCE_TARGET_NAMES, build_maintenance_target_catalog - -_MAINTENANCE_TARGET_HELP = build_maintenance_target_catalog().help_text() - CheckCommandDecorator = Callable[[Callable[..., object]], Callable[..., object]] CHECK_COMMAND_OPTION_DECORATORS: tuple[CheckCommandDecorator, ...] = ( click.option("--format", "-f", "output_format", type=click.Choice(["json"]), default=None, help="Output format"), click.option("--verbose", "-v", is_flag=True, help="Show breakdown by origin"), - click.option("--repair", is_flag=True, help="Run safe derived-data maintenance repairs"), - click.option( - "--cleanup", is_flag=True, help="Run destructive archive cleanup for orphaned or empty persisted data" - ), - click.option( - "--target", - "maintenance_targets", - multiple=True, - type=click.Choice(MAINTENANCE_TARGET_NAMES), - help=_MAINTENANCE_TARGET_HELP, - ), - click.option( - "--preview", is_flag=True, help="Preview maintenance without executing (requires --repair or --cleanup)" - ), - click.option( - "--vacuum", is_flag=True, help="Reclaim unused space after maintenance (requires --repair or --cleanup)" - ), click.option( "--deep", is_flag=True, diff --git a/polylogue/cli/shared/check_rendering_json.py b/polylogue/cli/shared/check_rendering_json.py index 6891f4b698..577b1db6ac 100644 --- a/polylogue/cli/shared/check_rendering_json.py +++ b/polylogue/cli/shared/check_rendering_json.py @@ -39,12 +39,4 @@ def emit_json_output(result: CheckCommandResult, options: CheckCommandOptions) - ) if result.blob_report is not None: out["blob_store"] = json_document(result.blob_report) - if result.maintenance_results is not None: - maintenance_payload = { - "targets": list(result.maintenance_targets), - "items": [repair.to_dict() for repair in result.maintenance_results], - } - out["maintenance"] = json_document(maintenance_payload) - if result.vacuum_result is not None: - out["vacuum"] = result.vacuum_result.to_dict() emit_success(out) diff --git a/polylogue/cli/shared/check_rendering_plain.py b/polylogue/cli/shared/check_rendering_plain.py index 0f5285da1b..643df1d686 100644 --- a/polylogue/cli/shared/check_rendering_plain.py +++ b/polylogue/cli/shared/check_rendering_plain.py @@ -2,10 +2,8 @@ from __future__ import annotations -import click - from polylogue.cli.shared.check_models import CheckCommandResult -from polylogue.cli.shared.check_support import format_count_mapping, run_vacuum +from polylogue.cli.shared.check_support import format_count_mapping from polylogue.cli.shared.check_workflow import CheckCommandOptions from polylogue.cli.shared.types import AppEnv from polylogue.daemon.status import format_daemon_status_lines @@ -244,48 +242,6 @@ def build_report_lines( return lines -# --------------------------------------------------------------------------- -# Maintenance output -# --------------------------------------------------------------------------- - - -def emit_maintenance_output( - env: AppEnv, - result: CheckCommandResult, - options: CheckCommandOptions, -) -> None: - """Render maintenance/correction output after the readiness report.""" - if result.maintenance_results is not None: - click.echo("") - mode_label = "Preview of maintenance" if options.preview else "Running maintenance" - click.echo(f"{mode_label}...") - if result.maintenance_targets: - click.echo(f" Targets: {', '.join(result.maintenance_targets)}") - total_repaired = 0 - for repair in result.maintenance_results: - if repair.repaired_count > 0 or not repair.success: - status = "[green]✓[/green]" if repair.success else "[red]✗[/red]" - if env.ui.plain: - status = "OK" if repair.success else "FAIL" - mode = f"{repair.category.value}{' destructive' if repair.destructive else ''}" - env.ui.console.print(f" {status} {repair.name} [{mode}]: {repair.detail}") - total_repaired += repair.repaired_count - - if total_repaired > 0: - action = "Would apply" if options.preview else "Applied" - click.echo(f"\n{action} {total_repaired:,} change(s)") - else: - click.echo(" No selected maintenance work was needed.") - elif options.repair or options.cleanup: - env.ui.console.print("No maintenance operations were selected.") - - if (options.repair or options.cleanup) and options.vacuum and options.preview: - env.ui.console.print("") - env.ui.console.print("Preview mode: VACUUM skipped.") - elif (options.repair or options.cleanup) and options.vacuum: - run_vacuum(env) - - # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- @@ -297,11 +253,9 @@ def render_plain_output( options: CheckCommandOptions, ) -> None: env.ui.summary("Health Check", build_report_lines(env, result, options)) - emit_maintenance_output(env, result, options) __all__ = [ - "emit_maintenance_output", "append_daemon_lines", "render_plain_output", "status_icon", diff --git a/polylogue/cli/shared/check_support.py b/polylogue/cli/shared/check_support.py index e8b3d27d0c..f2e11ec0a6 100644 --- a/polylogue/cli/shared/check_support.py +++ b/polylogue/cli/shared/check_support.py @@ -5,9 +5,7 @@ import sys import time -from polylogue.cli.shared.check_models import VacuumResult from polylogue.cli.shared.helpers import fail -from polylogue.cli.shared.types import AppEnv from polylogue.core.protocols import ProgressCallback @@ -52,27 +50,3 @@ def _cb(amount: int, desc: str | None = None) -> None: def make_schema_progress_callback() -> ProgressCallback: """Return a stderr progress reporter for schema verification.""" return make_count_progress_callback(label="Verifying schemas", unit="raw records") - - -def make_session_insight_progress_callback() -> ProgressCallback: - """Return a stderr progress reporter for session-insight repairs.""" - return make_count_progress_callback(label="Repairing session insights", unit="sessions") - - -def vacuum_database(env: AppEnv) -> VacuumResult: - """Run VACUUM and return a machine-readable result.""" - from polylogue.storage.sqlite.connection import open_connection - - try: - with open_connection(env.config.db_path) as conn: - conn.execute("VACUUM") - return VacuumResult(ok=True, detail="Running VACUUM to reclaim space...\n VACUUM complete.") - except Exception as exc: - return VacuumResult(ok=False, detail=f"Running VACUUM to reclaim space...\n VACUUM failed: {exc}") - - -def run_vacuum(env: AppEnv) -> None: - """Run VACUUM to reclaim unused space.""" - result = vacuum_database(env) - env.ui.console.print("") - env.ui.console.print(result.detail) diff --git a/polylogue/cli/shared/check_validation.py b/polylogue/cli/shared/check_validation.py index 77c9ee9a83..4d794ae207 100644 --- a/polylogue/cli/shared/check_validation.py +++ b/polylogue/cli/shared/check_validation.py @@ -5,20 +5,12 @@ from typing import TYPE_CHECKING from polylogue.cli.shared.helpers import fail -from polylogue.maintenance.targets import MaintenanceTargetMode, build_maintenance_target_catalog if TYPE_CHECKING: from polylogue.cli.shared.check_workflow import CheckCommandOptions def validate_check_options(options: CheckCommandOptions) -> None: - catalog = build_maintenance_target_catalog() - if options.vacuum and not (options.repair or options.cleanup): - fail("doctor", "--vacuum requires --repair or --cleanup") - if options.preview and not (options.repair or options.cleanup): - fail("doctor", "--preview requires --repair or --cleanup") - if options.maintenance_targets and not (options.repair or options.cleanup): - fail("doctor", "--target requires --repair or --cleanup") if options.blob_integrity_full and not options.check_blob: fail("doctor", "--full requires --blob") if options.schema_providers and not options.check_schemas: @@ -55,20 +47,6 @@ def validate_check_options(options: CheckCommandOptions) -> None: fail("doctor", "--artifact-limit must be a positive integer") if options.artifact_offset < 0: fail("doctor", "--artifact-offset must be >= 0") - if options.maintenance_targets: - selected = catalog.resolve(tuple(options.maintenance_targets)) - if ( - options.repair - and not options.cleanup - and not any(spec.mode is MaintenanceTargetMode.REPAIR for spec in selected) - ): - fail("doctor", "--target only selected cleanup targets while running --repair") - if ( - options.cleanup - and not options.repair - and not any(spec.mode is MaintenanceTargetMode.CLEANUP for spec in selected) - ): - fail("doctor", "--target only selected repair targets while running --cleanup") __all__ = ["validate_check_options"] diff --git a/polylogue/cli/shared/check_workflow.py b/polylogue/cli/shared/check_workflow.py index 9ef76bf012..211e68c2b4 100644 --- a/polylogue/cli/shared/check_workflow.py +++ b/polylogue/cli/shared/check_workflow.py @@ -5,24 +5,14 @@ import sys from dataclasses import dataclass -from polylogue.cli.shared.check_maintenance import ( - build_preview_counts as _build_preview_counts, -) -from polylogue.cli.shared.check_maintenance import ( - persist_maintenance_run, -) -from polylogue.cli.shared.check_maintenance import ( - resolve_selected_maintenance_targets as _resolve_selected_maintenance_targets, -) -from polylogue.cli.shared.check_models import CheckCommandResult, VacuumResult +from polylogue.cli.shared.check_models import CheckCommandResult from polylogue.cli.shared.check_validation import validate_check_options as _validate_check_options from polylogue.cli.shared.helpers import load_effective_config from polylogue.cli.shared.types import AppEnv from polylogue.config import Config from polylogue.core.json import JSONDocument, json_document -from polylogue.core.protocols import ProgressCallback from polylogue.daemon.status import daemon_status_payload -from polylogue.readiness import ReadinessReport, get_readiness, run_runtime_readiness +from polylogue.readiness import get_readiness, run_runtime_readiness from polylogue.schemas.operator.workflow import ( list_artifact_cohorts, list_artifact_observations, @@ -35,13 +25,10 @@ ArtifactObservationQuery, SchemaVerificationRequest, ) -from polylogue.storage.repair import run_selected_maintenance from .check_support import ( make_schema_progress_callback, - make_session_insight_progress_callback, parse_schema_samples, - vacuum_database, ) @@ -49,10 +36,6 @@ class CheckCommandOptions: json_output: bool verbose: bool - repair: bool - cleanup: bool - preview: bool - vacuum: bool deep: bool runtime: bool check_daemon: bool @@ -72,13 +55,6 @@ class CheckCommandOptions: schema_record_limit: int | None schema_record_offset: int schema_quarantine_malformed: bool - maintenance_targets: tuple[str, ...] - - -@dataclass(frozen=True) -class _MaintenanceRunInputs: - selected_targets: tuple[str, ...] - preview_counts: dict[str, int] | None def validate_check_options(options: CheckCommandOptions) -> None: @@ -88,10 +64,6 @@ def validate_check_options(options: CheckCommandOptions) -> None: def _runtime_only_requested(options: CheckCommandOptions) -> bool: return options.runtime and not any( ( - options.repair, - options.cleanup, - options.preview, - options.vacuum, options.deep, options.check_daemon, options.check_blob, @@ -144,79 +116,13 @@ def _run_schema_verification(options: CheckCommandOptions, config: Config) -> Sc return report -def _session_insight_progress_callback( - options: CheckCommandOptions, - selected_targets: tuple[str, ...], -) -> ProgressCallback | None: - if ( - options.repair - and not options.preview - and not options.json_output - and (not selected_targets or "session_insights" in selected_targets) - ): - return make_session_insight_progress_callback() - return None - - -def _maintenance_run_inputs(options: CheckCommandOptions, report: ReadinessReport) -> _MaintenanceRunInputs: - return _MaintenanceRunInputs( - selected_targets=_resolve_selected_maintenance_targets(options), - preview_counts=_build_preview_counts(report) if options.preview else None, - ) - - -def _run_maintenance( - config: Config, - result: CheckCommandResult, - options: CheckCommandOptions, - inputs: _MaintenanceRunInputs, -) -> None: - result.maintenance_targets = inputs.selected_targets - result.maintenance_results = run_selected_maintenance( - config, - repair=options.repair, - cleanup=options.cleanup, - dry_run=options.preview, - preview_counts=inputs.preview_counts, - targets=inputs.selected_targets, - session_insight_progress_callback=_session_insight_progress_callback(options, inputs.selected_targets), - ) - - -def _persist_maintenance_run( - env: AppEnv, - *, - report: ReadinessReport, - result: CheckCommandResult, - options: CheckCommandOptions, - inputs: _MaintenanceRunInputs, -) -> None: - persist_maintenance_run( - env, - report=report, - options=options, - targets=inputs.selected_targets, - maintenance_results=result.maintenance_results or [], - vacuum_result=result.vacuum_result, - preview_counts=inputs.preview_counts, - ) - - def run_check_workflow(env: AppEnv, options: CheckCommandOptions) -> CheckCommandResult: config = load_effective_config(env) if _runtime_only_requested(options): return CheckCommandResult(report=run_runtime_readiness(config)) - explicit_target_probe = ( - (options.repair or options.cleanup) and bool(options.maintenance_targets) and not options.deep - ) - report = get_readiness( - config, - deep=options.deep, - probe_only=explicit_target_probe or not (options.deep or options.repair or options.cleanup), - ) + report = get_readiness(config, deep=options.deep, probe_only=not options.deep) result = CheckCommandResult(report=report) - maintenance_inputs: _MaintenanceRunInputs | None = None if options.runtime: result.runtime_report = run_runtime_readiness(config) @@ -252,23 +158,4 @@ def run_check_workflow(env: AppEnv, options: CheckCommandOptions) -> CheckComman db_path=config.db_path, ).rows - if options.repair or options.cleanup: - maintenance_inputs = _maintenance_run_inputs(options, report) - _run_maintenance(config, result, options, maintenance_inputs) - - if (options.repair or options.cleanup) and options.vacuum: - if options.preview: - result.vacuum_result = VacuumResult(ok=True, preview=True, detail="Preview mode: VACUUM skipped.") - elif options.json_output: - result.vacuum_result = vacuum_database(env) - - if result.maintenance_results is not None and maintenance_inputs is not None: - _persist_maintenance_run( - env, - report=report, - result=result, - options=options, - inputs=maintenance_inputs, - ) - return result diff --git a/polylogue/config.py b/polylogue/config.py index d2db5753c9..2e28f0a6ea 100644 --- a/polylogue/config.py +++ b/polylogue/config.py @@ -189,7 +189,7 @@ def active_archive_root(config: Config) -> Path: Deliberately follows ``config.db_path`` (not ``config.archive_root``), matching the ``polylogue-yla8.1`` split-root contract used by - :func:`polylogue.storage.repair._raw_materialization_archive_root` and + :func:`polylogue.storage.raw_convergence._raw_materialization_archive_root` and :func:`polylogue.storage.raw_reconciler._archive_root`: an explicit ``Config(db_path=...)`` override must be honored, and the ordinary case already resolves ``config.db_path`` correctly (``.index-active-pointer`` diff --git a/polylogue/daemon/cli.py b/polylogue/daemon/cli.py index 047d1ca446..32e231ae04 100644 --- a/polylogue/daemon/cli.py +++ b/polylogue/daemon/cli.py @@ -119,7 +119,7 @@ # maintenance-priority admission (PR #3289) bounds worst-case queued-actor # wait to roughly "this pass's remaining budget + at most one more # already-queued, equally-bounded ingest hold" instead of an unbounded -# multi-minute wait. ``repair_raw_materialization`` checks this budget only +# multi-minute wait. ``converge_raw_materialization`` checks this budget only # between components, at a point it already commits and requeries candidates # -- a real transaction-boundary checkpoint, not a mid-write yield -- and # always completes at least one component regardless of the budget, so a @@ -1220,7 +1220,7 @@ def _drain_raw_materialization_once( auto_resolved, ) try: - result = raw_authority.repair_materialization( + result = raw_authority.converge_materialization( config, dry_run=False, raw_artifact_limit=limit, @@ -1319,7 +1319,7 @@ def _run_raw_materialization_whale_pass_once( result = refused_result else: try: - result = raw_authority.repair_materialization( + result = raw_authority.converge_materialization( config, dry_run=False, raw_artifact_limit=1, diff --git a/polylogue/daemon/convergence_stages.py b/polylogue/daemon/convergence_stages.py index 4f58519d45..c382db649b 100644 --- a/polylogue/daemon/convergence_stages.py +++ b/polylogue/daemon/convergence_stages.py @@ -838,10 +838,10 @@ def barrier_many(paths: Sequence[Path]) -> set[Path]: _RAW_PARSE_RECOVERY_BATCH_LIMIT = 200 -# Mirrors ``storage.repair.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES`` as a +# Mirrors ``storage.raw_convergence.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES`` as a # local literal rather than importing it: new surface code (this stage lives # in ``daemon/``) should not import substrate (``storage``) internals -# directly per this repo's layering ratchet, and ``repair_materialization``'s +# directly per this repo's layering ratchet, and ``converge_materialization``'s # ``max_payload_bytes`` is a plain bound this stage can restate on its own. _RAW_PARSE_RECOVERY_MAX_PAYLOAD_BYTES = 1024 * 1024 * 1024 @@ -854,7 +854,7 @@ def _raw_parse_recovery_pending_count(db_path: Path, path: Path, *, archive_root ``sessions`` row for the raw (by raw_id or native-id alias) and no terminal parse error recorded. It intentionally does not replicate the full authority/quarantine/byte-authority classification -- that - refinement happens inside ``repair_raw_materialization`` itself during + refinement happens inside ``converge_raw_materialization`` itself during ``execute``; this is only a cheap "is there plausibly pending work here" probe so ``check`` stays fast and false positives just cost one wasted ``execute`` call rather than silently missing real backlog. @@ -964,7 +964,7 @@ def make_raw_parse_recovery_stage(db_path: Path, *, archive_root: Path | None = ``raw_parse_recovery`` convergence-debt row per source path that attempt covered. This stage is what actually drains that debt: ``check`` reports whether raw rows under the path are still acquired but never - materialized, and ``execute`` re-drives ``repair_raw_materialization`` + materialized, and ``execute`` re-drives ``converge_raw_materialization`` scoped to exactly that path via ``source_root`` -- the same replay engine the archive-wide trickle conveyor already uses, just requeued deterministically instead of waiting for an accidental future touch of @@ -976,7 +976,7 @@ def check(path: Path) -> bool: def execute(path: Path) -> StageExecuteReturn: from polylogue.config import Config - from polylogue.maintenance.raw_authority import repair_materialization + from polylogue.maintenance.raw_authority import converge_materialization from polylogue.readiness.capability import raw_frontier_source_selection_block_reason configured_root = archive_root or db_path.parent @@ -989,7 +989,7 @@ def execute(path: Path) -> StageExecuteReturn: return False config = Config(archive_root=configured_root, render_root=configured_root, sources=[]) try: - repair_materialization( + converge_materialization( config, dry_run=False, raw_artifact_limit=_RAW_PARSE_RECOVERY_BATCH_LIMIT, diff --git a/polylogue/daemon/health.py b/polylogue/daemon/health.py index 1541ac3c3a..b73a125150 100644 --- a/polylogue/daemon/health.py +++ b/polylogue/daemon/health.py @@ -669,15 +669,7 @@ def _check_fts_readiness_medium() -> HealthAlert: def _check_raw_failures_medium() -> HealthAlert: - """Check raw session parse/validation/maintenance failure counts. - - Maintenance failures routed via - :func:`polylogue.maintenance.failure_routing.route_failure_sample` - (#1198) participate in the same alert ladder as ingest failures. - When the maintenance bucket dominates, the message names a - representative ``operation_id`` so the operator can pull the - originating replay state file directly. - """ + """Check raw session parse/validation failure counts.""" now = datetime.now(UTC).isoformat() try: from polylogue.daemon.status import _raw_failure_info @@ -711,26 +703,13 @@ def _check_raw_failures_medium() -> HealthAlert: raw_val = info.get("validation_failures", 0) validation = int(raw_val) if isinstance(raw_val, (int, float)) else 0 quarantined = info.get("quarantined", 0) if isinstance(info.get("quarantined"), int) else 0 - raw_maint = info.get("maintenance_failures", 0) - maintenance = int(raw_maint) if isinstance(raw_maint, (int, float)) else 0 raw_deferred = info.get("deferred_failures", 0) deferred = int(raw_deferred) if isinstance(raw_deferred, (int, float)) else 0 raw_terminal = info.get("terminal_rejections", 0) terminal = int(raw_terminal) if isinstance(raw_terminal, (int, float)) else 0 raw_unexplained = info.get("unexplained_failures") unexplained = int(raw_unexplained) if isinstance(raw_unexplained, (int, float)) else parse + validation - total_failures = unexplained + maintenance - - op_hint = "" - if maintenance > 0: - samples = info.get("samples", []) - if isinstance(samples, list): - for sample in samples: - op_id = getattr(sample, "operation_id", None) - src = getattr(sample, "source", None) - if src == "maintenance" and op_id: - op_hint = f" (op={str(op_id)[:8]})" - break + total_failures = unexplained if total_failures == 0 and deferred == 0: severity = HealthSeverity.OK @@ -745,28 +724,13 @@ def _check_raw_failures_medium() -> HealthAlert: message = f"{deferred} deferred retryable raw capture(s){terminal_context}; daemon work remains pending" elif total_failures <= _RAW_FAILURE_WARN_COUNT: severity = HealthSeverity.WARNING - message = ( - f"{total_failures} unexplained raw failures ({quarantined} quarantined, {maintenance} maintenance, " - f"{deferred} deferred, {terminal} terminal){op_hint}" - if maintenance - else f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" - ) + message = f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" elif total_failures <= _RAW_FAILURE_ERROR_COUNT: severity = HealthSeverity.ERROR - message = ( - f"{total_failures} unexplained raw failures ({quarantined} quarantined, {maintenance} maintenance, " - f"{deferred} deferred, {terminal} terminal){op_hint}" - if maintenance - else f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" - ) + message = f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" else: severity = HealthSeverity.CRITICAL - base = ( - f"{total_failures} unexplained raw failures ({quarantined} quarantined, {maintenance} maintenance, " - f"{deferred} deferred, {terminal} terminal){op_hint}" - if maintenance - else f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" - ) + base = f"{total_failures} unexplained raw failures ({quarantined} quarantined, {deferred} deferred, {terminal} terminal)" message = f"{base}; investigation needed" return HealthAlert( check_name="raw_failures", diff --git a/polylogue/daemon/http.py b/polylogue/daemon/http.py index 2bf78a6aec..4998cba120 100644 --- a/polylogue/daemon/http.py +++ b/polylogue/daemon/http.py @@ -723,38 +723,6 @@ def _archive_filter_kwargs_from_spec( } -_SCOPE_FILTER_KEYS = frozenset( - { - "session_ids", - "origin", - "source_family", - "source_root", - "time_range", - "failure_kind", - "parser_version", - } -) - - -def _parse_scope_filter_body(body: dict[str, Any]) -> dict[str, Any]: - """Extract scope-filter fields from a maintenance POST body. - - Accepts both a nested ``{"scope": {"filter": {...}}}`` envelope and - a flat top-level shape (``session_ids`` etc. directly on the - body). The flat form is the one the CLI's ``--output-format json`` - plan reuses when an operator pipes it back to the daemon, so - parity with the CLI is what pins the daemon-side parser. - """ - - scope = body.get("scope") - if isinstance(scope, dict): - scope_filter = scope.get("filter") - if isinstance(scope_filter, dict): - return dict(scope_filter) - # Fall back to flat keys on the body itself. - return {key: body[key] for key in _SCOPE_FILTER_KEYS if key in body} - - def _dump_target_ref(target_ref: TargetRefPayload) -> dict[str, object]: return target_ref.model_dump(mode="json", exclude_none=True) diff --git a/polylogue/daemon/status.py b/polylogue/daemon/status.py index 588f83cb04..f7f879569c 100644 --- a/polylogue/daemon/status.py +++ b/polylogue/daemon/status.py @@ -74,8 +74,8 @@ raw_materialization_readiness_snapshot, raw_materialization_ready, ) +from polylogue.storage.raw_convergence import raw_materialization_replay_backlog from polylogue.storage.raw_retention import raw_frontier_integrity_projection, raw_frontier_integrity_summary -from polylogue.storage.repair import raw_materialization_replay_backlog from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.connection_profile import open_readonly_connection @@ -404,23 +404,15 @@ class RawFailureSample(BaseModel): table and consumed by the typed ``DaemonStatus`` model. Every surface (CLI, MCP, daemon HTTP) receives the same structured taxonomy. - Two failure surfaces share this envelope: - - * ``source == "ingest"`` (default) — failures observed on - :mod:`polylogue.pipeline` parse/validate paths; the ``raw_id`` - and provider-specific signals come from ``raw_sessions``. - * ``source == "maintenance"`` — failures observed on - :mod:`polylogue.maintenance.replay` per-record paths, routed via - :func:`polylogue.maintenance.failure_routing.route_failure_sample` - and reattached to status here. They carry the originating - :attr:`operation_id` and the typed planner :attr:`locator`. + Failures are observed on :mod:`polylogue.pipeline` parse/validate + paths; the ``raw_id`` and provider-specific signals come from + ``raw_sessions``. """ failure_kind: Literal[ "decode_error", "parse_error", "schema_violation", - "maintenance", "unknown", "deferred_hot_jsonl_capture", "deferred_claude_code_partial_jsonl", @@ -433,9 +425,6 @@ class RawFailureSample(BaseModel): ] provider_hint: str | None = None redacted_error: str = "" - source: Literal["ingest", "maintenance"] = "ingest" - operation_id: str | None = None - locator: str | None = None lifecycle: Literal["deferred", "terminal", "unexplained"] | None = None @field_validator("redacted_error", mode="before") @@ -519,7 +508,6 @@ class DaemonStatus(BaseModel): raw_parse_failures: int = 0 raw_validation_failures: int = 0 raw_quarantined: int = 0 - raw_maintenance_failures: int = 0 raw_deferred_failures: int = 0 raw_terminal_rejections: int = 0 raw_unexplained_failures: int = 0 @@ -856,80 +844,37 @@ def _archive_insight_freshness_info(archive_db: Path) -> dict[str, object] | Non def _raw_failure_info() -> dict[str, object]: - """Query raw_sessions + maintenance routing for failure counts and samples. - - The payload merges two failure surfaces (#1198): - - * ``parse_failures`` / ``validation_failures`` / ``quarantined`` / - ``detection_warnings`` come from the ``raw_sessions`` table - (ingest path); - * ``maintenance_failures`` comes from the JSONL file written by - :func:`polylogue.maintenance.failure_routing.route_failure_sample` - (replay path). - - ``samples`` interleaves both surfaces — newest first — capped at - 50 entries total. Each sample carries ``source`` so consumers can - distinguish ``"ingest"`` rows from ``"maintenance"`` rows without - re-querying. - """ + """Query raw_sessions for failure counts and bounded samples (newest first, capped at 50).""" root = archive_root() - source_db = root / "source.db" - maintenance_samples, maintenance_count, maintenance_error = _maintenance_failure_info() - if maintenance_error is not None: - return _unavailable_raw_failure_info( - reason=f"maintenance failure ledger unavailable: {maintenance_error}", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, - ) - archive_info = _archive_raw_failure_info( - source_db, - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, - ) - return archive_info + return _archive_raw_failure_info(root / "source.db") -def _unavailable_raw_failure_info( - *, - reason: str, - maintenance_samples: list[RawFailureSample], - maintenance_count: int, -) -> dict[str, object]: +def _unavailable_raw_failure_info(*, reason: str) -> dict[str, object]: """Represent missing source evidence without manufacturing zero counts.""" return { "parse_failures": 0, "validation_failures": 0, "quarantined": 0, "detection_warnings": 0, - "maintenance_failures": maintenance_count, "deferred_failures": 0, "terminal_rejections": 0, "unexplained_failures": 0, "raw_failure_lifecycle_available": False, "raw_failure_lifecycle_state": "unavailable", "raw_failure_lifecycle_reason": reason, - "samples": maintenance_samples, + "samples": [], } -def _archive_raw_failure_info( - archive_db: Path, - *, - maintenance_samples: list[RawFailureSample], - maintenance_count: int, -) -> dict[str, object]: +def _archive_raw_failure_info(archive_db: Path) -> dict[str, object]: if not archive_db.exists(): return _unavailable_raw_failure_info( reason=f"source.db not found: {archive_db}", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, ) lifecycle_snapshot = read_raw_failure_lifecycle(archive_db, sample_limit=50) if not lifecycle_snapshot.available: return _unavailable_raw_failure_info( reason=lifecycle_snapshot.reason or "raw failure lifecycle is unavailable", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, ) try: conn = open_readonly_connection(archive_db, validate_schema=False) @@ -937,8 +882,6 @@ def _archive_raw_failure_info( if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='raw_sessions'").fetchone(): return _unavailable_raw_failure_info( reason="source.db is missing raw_sessions", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, ) parse_fail = lifecycle_snapshot.parse_failures validation_fail = lifecycle_snapshot.validation_failures @@ -1024,16 +967,12 @@ def _archive_raw_failure_info( ) finally: conn.close() - combined: list[RawFailureSample] = list(samples) - combined.extend(maintenance_samples) - if len(combined) > 50: - combined = combined[:50] + combined: list[RawFailureSample] = list(samples)[:50] return { "parse_failures": parse_fail, "validation_failures": validation_fail, "quarantined": quarantined, "detection_warnings": detection_warnings_count, - "maintenance_failures": maintenance_count, "deferred_failures": lifecycle_snapshot.deferred, "terminal_rejections": lifecycle_snapshot.terminal, "unexplained_failures": lifecycle_snapshot.unexplained, @@ -1045,26 +984,12 @@ def _archive_raw_failure_info( logger.warning("status: raw-failure query failed for %s: %s", archive_db, exc, exc_info=True) return _unavailable_raw_failure_info( reason=f"could not read source.db raw failure relations: {exc}", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, ) def raw_failure_info_for_root(root: Path) -> dict[str, object]: """Return raw lifecycle evidence from one archive root without a daemon.""" - maintenance_samples, maintenance_count, maintenance_error = _maintenance_failure_info(root) - if maintenance_error is not None: - return _unavailable_raw_failure_info( - reason=f"maintenance failure ledger unavailable: {maintenance_error}", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, - ) - archive_info = _archive_raw_failure_info( - root / "source.db", - maintenance_samples=maintenance_samples, - maintenance_count=maintenance_count, - ) - return archive_info + return _archive_raw_failure_info(root / "source.db") def raw_failure_lifecycle_for_root(root: Path) -> Any: @@ -1072,45 +997,6 @@ def raw_failure_lifecycle_for_root(root: Path) -> Any: return read_raw_failure_lifecycle(root / "source.db", sample_limit=0) -def _maintenance_failure_info(root: Path | None = None) -> tuple[list[RawFailureSample], int, str | None]: - """Read routed maintenance failures into typed daemon samples (#1198). - - Returns ``(samples, total_count, read_error)`` so the caller can surface - bounded samples, report the absolute count to the raw-failures health - check, and distinguish an empty ledger from an unreadable one. - """ - from polylogue.maintenance.failure_routing import ( - count_maintenance_failures, - read_maintenance_failures_with_error, - ) - - try: - archive = root or archive_root() - records, read_error = read_maintenance_failures_with_error(archive) - total = count_maintenance_failures(archive) - except Exception as exc: - logger.warning("status: maintenance-failure read failed: %s", exc, exc_info=True) - return [], 0, str(exc) - - if read_error is not None: - logger.warning("status: maintenance-failure read failed: %s", read_error) - return [], total, read_error - - samples: list[RawFailureSample] = [] - for record in records: - samples.append( - RawFailureSample( - failure_kind="maintenance", - provider_hint=record.target or None, - redacted_error=f"{record.kind}: {record.message}" if record.kind else record.message, - source="maintenance", - operation_id=record.operation_id or None, - locator=record.locator or None, - ) - ) - return samples, total, None - - def _typed_failure_samples(value: object) -> list[RawFailureSample]: """Safely extract typed failure samples from a potentially heterogeneous source.""" if isinstance(value, list): @@ -2751,7 +2637,6 @@ def _v(name: str, default: Any) -> Any: raw_parse_failures=_safe_int(raw_failures.get("parse_failures", 0)), raw_validation_failures=_safe_int(raw_failures.get("validation_failures", 0)), raw_quarantined=_safe_int(raw_failures.get("quarantined", 0)), - raw_maintenance_failures=_safe_int(raw_failures.get("maintenance_failures", 0)), raw_deferred_failures=_safe_int(raw_failures.get("deferred_failures", 0)), raw_terminal_rejections=_safe_int(raw_failures.get("terminal_rejections", 0)), raw_unexplained_failures=_safe_int(raw_failures.get("unexplained_failures", 0)), @@ -2975,7 +2860,6 @@ def daemon_status_payload( "raw_parse_failures": status.raw_parse_failures, "raw_validation_failures": status.raw_validation_failures, "raw_quarantined": status.raw_quarantined, - "raw_maintenance_failures": status.raw_maintenance_failures, "raw_deferred_failures": status.raw_deferred_failures, "raw_terminal_rejections": status.raw_terminal_rejections, "raw_unexplained_failures": status.raw_unexplained_failures, @@ -3434,7 +3318,6 @@ def format_daemon_status_lines(payload: JSONDocument) -> list[str]: raw_parse = _safe_int(payload.get("raw_parse_failures")) raw_val = _safe_int(payload.get("raw_validation_failures")) raw_quarantined = _safe_int(payload.get("raw_quarantined")) - raw_maintenance = _safe_int(payload.get("raw_maintenance_failures")) raw_deferred = _safe_int(payload.get("raw_deferred_failures")) raw_terminal = _safe_int(payload.get("raw_terminal_rejections")) raw_unexplained = _safe_int(payload.get("raw_unexplained_failures")) @@ -3444,11 +3327,9 @@ def format_daemon_status_lines(payload: JSONDocument) -> list[str]: if raw_lifecycle_unavailable or lifecycle_state == "blocked": lifecycle_reason = str(payload.get("raw_failure_lifecycle_reason") or "source.db evidence is unavailable") lines.append(f"Raw failures: {lifecycle_state} ({lifecycle_reason})") - total_raw = raw_parse + raw_val + raw_maintenance + total_raw = raw_parse + raw_val if not raw_lifecycle_unavailable and total_raw > 0: breakdown = f"{raw_parse} parse + {raw_val} validation" - if raw_maintenance > 0: - breakdown += f" + {raw_maintenance} maintenance" lines.append(f"Raw failures: {total_raw} total ({raw_quarantined} quarantined), {breakdown}") lines.append( f" Lifecycle: {raw_deferred} deferred retryable, {raw_terminal} terminal, {raw_unexplained} unexplained" @@ -3460,12 +3341,7 @@ def format_daemon_status_lines(payload: JSONDocument) -> list[str]: kind = s.get("failure_kind", "unknown") hint = s.get("provider_hint") or "?" error_text = str(s.get("redacted_error", "")) - source = s.get("source", "ingest") - op_id = s.get("operation_id") - suffix = "" - if source == "maintenance" and op_id: - suffix = f" (op={str(op_id)[:8]})" - lines.append(f" [{kind}] {hint}: {error_text[:120]}{suffix}") + lines.append(f" [{kind}] {hint}: {error_text[:120]}") # Embedding readiness embedding = payload.get("embedding_readiness") if isinstance(embedding, dict): diff --git a/polylogue/daemon/status_snapshot.py b/polylogue/daemon/status_snapshot.py index 64ef8f8a93..4f209dbab2 100644 --- a/polylogue/daemon/status_snapshot.py +++ b/polylogue/daemon/status_snapshot.py @@ -258,7 +258,6 @@ def _minimal_status_payload(*, refresh_in_progress: bool = False, refresh_error: "raw_parse_failures": 0, "raw_validation_failures": 0, "raw_quarantined": 0, - "raw_maintenance_failures": 0, "raw_deferred_failures": 0, "raw_terminal_rejections": 0, "raw_unexplained_failures": 0, diff --git a/polylogue/insights/archive.py b/polylogue/insights/archive.py index efc7831512..52d716c758 100644 --- a/polylogue/insights/archive.py +++ b/polylogue/insights/archive.py @@ -44,7 +44,6 @@ time_confidence_for_sources, weakest_of, ) -from polylogue.storage.repair import ArchiveDebtStatus from polylogue.storage.runtime.store_constants import SESSION_INSIGHT_MATERIALIZER_VERSION if TYPE_CHECKING: @@ -568,18 +567,6 @@ class ArchiveDebtInsight(ArchiveInsightModel): healthy: bool detail: str - @classmethod - def from_status(cls, status: ArchiveDebtStatus) -> ArchiveDebtInsight: - return cls( - debt_name=status.name, - category=status.category.value, - maintenance_target=status.maintenance_target, - destructive=status.destructive, - issue_count=status.issue_count, - healthy=status.healthy, - detail=status.detail, - ) - def profile_bucket_day(profile: SessionProfile) -> date | None: if profile.canonical_session_date is not None: diff --git a/polylogue/insights/readiness.py b/polylogue/insights/readiness.py index 48db124b56..f7d162b9d3 100644 --- a/polylogue/insights/readiness.py +++ b/polylogue/insights/readiness.py @@ -11,14 +11,13 @@ from polylogue.archive.query.spec import parse_query_date from polylogue.insights.archive_models import ARCHIVE_INSIGHT_CONTRACT_VERSION, ArchiveInsightModel -from polylogue.maintenance.targets import build_maintenance_target_catalog from polylogue.storage.insights.session.runtime import SessionInsightStatusSnapshot from polylogue.storage.introspection import table_exists_async as _table_exists InsightReadinessVerdict = Literal[ "ready", "partial", "empty", "missing", "stale", "incompatible", "degraded", "unknown" ] -_REPAIR_HINT = build_maintenance_target_catalog().repair_hint(("session_insights",), include_run_all=True) +_REPAIR_HINT = "Run `polylogued run`." def _origin_value(origin: str | None) -> str | None: diff --git a/polylogue/maintenance/__init__.py b/polylogue/maintenance/__init__.py index f114ecc208..4b5e9659d5 100644 --- a/polylogue/maintenance/__init__.py +++ b/polylogue/maintenance/__init__.py @@ -1,4 +1,4 @@ -"""Fail-closed integrity verification and operator-supervised repair workflows.""" +"""Fail-closed integrity verification and guarded recovery workflows.""" # Public maintenance primitive used by candidate acceptance and offline # verification. Keeping the import here makes the owner visible to callers. diff --git a/polylogue/maintenance/blob_reference_closure.py b/polylogue/maintenance/blob_reference_closure.py index 173589e200..b376d5a74f 100644 --- a/polylogue/maintenance/blob_reference_closure.py +++ b/polylogue/maintenance/blob_reference_closure.py @@ -1,136 +1,8 @@ -"""Read-only audit and guarded repair for acquired blob-reference closure.""" +"""Read-only structural closure law for acquired blob references.""" from __future__ import annotations -import hashlib -import json -import os import sqlite3 -import time -from contextlib import suppress -from dataclasses import dataclass -from enum import StrEnum -from pathlib import Path - -from polylogue.config import Config -from polylogue.maintenance.offline_guard import offline_maintenance_block_reason -from polylogue.paths import render_root -from polylogue.storage.attachment_relink import ( - MAX_ATTACHMENT_SAMPLE_LIMIT, - OrphanedAttachmentRelinkPlan, - RawSessionParser, - RelinkableAttachment, - UnrecoverableAttachmentReason, - plan_orphaned_attachment_relink, -) -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier -from polylogue.storage.sqlite.migration_runner import ( - validate_backup_manifest_covers_derived_tier, - validate_migration_backup_manifest, -) - -TOOL_VERSION = "blob-reference-closure-v1" - - -class BlobReferenceClosureError(RuntimeError): - """Raised when a guarded closure repair cannot prove its write set.""" - - -class BlobReferenceBlockerKind(StrEnum): - """Typed reasons a closure row cannot be repaired by this route.""" - - RAW_NONEXACT_REFERENCE = "raw_nonexact_reference" - RAW_MISSING_AUTHORITATIVE_FIELD = "raw_missing_authoritative_field" - ATTACHMENT_NO_AUTHORITATIVE_RAW = "attachment_no_authoritative_raw" - ATTACHMENT_MESSAGE_MISSING = "attachment_message_missing" - - -@dataclass(frozen=True, slots=True) -class BlobReferenceClosureBlocker: - kind: BlobReferenceBlockerKind - object_id: str - detail: str - - def to_dict(self) -> dict[str, str]: - return {"kind": self.kind.value, "object_id": self.object_id, "detail": self.detail} - - -@dataclass(frozen=True, slots=True) -class RawBlobReferenceCandidate: - raw_id: str - blob_hash: bytes - source_path: str - blob_size: int - acquired_at_ms: int - - def to_dict(self) -> dict[str, object]: - return { - "raw_id": self.raw_id, - "blob_hash": self.blob_hash.hex(), - "source_path": self.source_path, - "blob_size": self.blob_size, - "acquired_at_ms": self.acquired_at_ms, - } - - -@dataclass(frozen=True, slots=True) -class BlobReferenceClosurePlan: - raw_candidates: tuple[RawBlobReferenceCandidate, ...] - attachment_candidates: tuple[RelinkableAttachment, ...] - blockers: tuple[BlobReferenceClosureBlocker, ...] - raw_rows_scanned: int - raw_rows_total: int - attachment_orphan_count: int - attachment_blockers_sampled: bool = False - - @property - def candidate_count(self) -> int: - return len(self.raw_candidates) + len(self.attachment_candidates) - - def to_dict(self) -> dict[str, object]: - return { - "raw_candidate_count": len(self.raw_candidates), - "attachment_candidate_count": len(self.attachment_candidates), - "candidate_count": self.candidate_count, - "raw_rows_scanned": self.raw_rows_scanned, - "raw_rows_total": self.raw_rows_total, - "attachment_orphan_count": self.attachment_orphan_count, - "attachment_blockers_sampled": self.attachment_blockers_sampled, - "blocker_count": len(self.blockers), - "blockers": [blocker.to_dict() for blocker in self.blockers], - } - - -@dataclass(frozen=True, slots=True) -class BlobReferenceClosureReport: - archive_root: str - dry_run: bool - applied: bool - plan: BlobReferenceClosurePlan - raw_repaired_count: int = 0 - attachment_repaired_count: int = 0 - backup_manifest: Path | None = None - receipt_path: Path | None = None - - def to_dict(self) -> dict[str, object]: - return { - "archive_root": self.archive_root, - "dry_run": self.dry_run, - "applied": self.applied, - "raw_repaired_count": self.raw_repaired_count, - "attachment_repaired_count": self.attachment_repaired_count, - "backup_manifest": str(self.backup_manifest) if self.backup_manifest is not None else None, - "receipt_path": str(self.receipt_path) if self.receipt_path is not None else None, - "plan": self.plan.to_dict(), - } - - -def _offline_config(archive_root: Path) -> Config: - return Config(archive_root=archive_root, render_root=render_root(), sources=[]) - - -def _open_ro(path: Path) -> sqlite3.Connection: - return sqlite3.connect(f"file:{path}?mode=ro", uri=True) def raw_reference_closure_predicate(raw_alias: str = "r", ref_alias: str = "b") -> str: @@ -152,405 +24,6 @@ def raw_reference_closure_predicate(raw_alias: str = "r", ref_alias: str = "b") """ -def _raw_candidates_and_blockers( - conn: sqlite3.Connection, -) -> tuple[list[RawBlobReferenceCandidate], list[BlobReferenceClosureBlocker], int]: - rows = conn.execute( - f""" - SELECT r.raw_id, r.blob_hash, r.source_path, r.blob_size, r.acquired_at_ms, - (SELECT COUNT(*) FROM blob_refs b - WHERE b.ref_type = 'raw_payload' AND b.ref_id = r.raw_id) AS ref_count, - (SELECT COUNT(*) FROM blob_refs b - WHERE b.ref_type = 'raw_payload' AND b.ref_id = r.raw_id - AND b.blob_hash = r.blob_hash) AS exact_count - FROM raw_sessions r - WHERE {raw_reference_closure_predicate()} - ORDER BY r.raw_id - """ - ).fetchall() - candidates: list[RawBlobReferenceCandidate] = [] - blockers: list[BlobReferenceClosureBlocker] = [] - for raw_id, blob_hash, source_path, blob_size, acquired_at_ms, ref_count, exact_count in rows: - if source_path is None or blob_size is None or acquired_at_ms is None: - blockers.append( - BlobReferenceClosureBlocker( - BlobReferenceBlockerKind.RAW_MISSING_AUTHORITATIVE_FIELD, - str(raw_id), - "raw_sessions lacks source_path, blob_size, or acquired_at_ms", - ) - ) - elif exact_count == 0 and ref_count == 0: - candidates.append( - RawBlobReferenceCandidate( - raw_id=str(raw_id), - blob_hash=bytes(blob_hash), - source_path=str(source_path), - blob_size=int(blob_size), - acquired_at_ms=int(acquired_at_ms), - ) - ) - else: - blockers.append( - BlobReferenceClosureBlocker( - BlobReferenceBlockerKind.RAW_NONEXACT_REFERENCE, - str(raw_id), - f"expected exactly one exact raw_payload ref; ref_count={ref_count}, exact_count={exact_count}", - ) - ) - total = int(conn.execute("SELECT COUNT(*) FROM raw_sessions").fetchone()[0]) - return candidates, blockers, total - - -def _attachment_blockers(plan: OrphanedAttachmentRelinkPlan) -> list[BlobReferenceClosureBlocker]: - blockers: list[BlobReferenceClosureBlocker] = [] - for item in plan.unrecoverable_samples[:MAX_ATTACHMENT_SAMPLE_LIMIT]: - if item.reason_kind is UnrecoverableAttachmentReason.MESSAGE_MISSING: - kind = BlobReferenceBlockerKind.ATTACHMENT_MESSAGE_MISSING - else: - kind = BlobReferenceBlockerKind.ATTACHMENT_NO_AUTHORITATIVE_RAW - blockers.append(BlobReferenceClosureBlocker(kind, item.attachment_id, item.reason)) - return blockers - - -def _acquired_attachment_ids(conn: sqlite3.Connection) -> set[str]: - rows = conn.execute( - """ - SELECT a.attachment_id - FROM attachments a - WHERE a.acquisition_status = 'acquired' - AND NOT EXISTS (SELECT 1 FROM attachment_refs r WHERE r.attachment_id = a.attachment_id) - """ - ).fetchall() - return {str(row[0]) for row in rows} - - -def _plan_connections( - index_conn: sqlite3.Connection, - source_conn: sqlite3.Connection, - *, - archive_root: Path, - sample_limit: int, - raw_session_parser: RawSessionParser | None = None, -) -> BlobReferenceClosurePlan: - raw_candidates, raw_blockers, raw_total = _raw_candidates_and_blockers(source_conn) - acquired_attachment_ids = _acquired_attachment_ids(index_conn) - attachment_plan = plan_orphaned_attachment_relink( - index_conn, - source_conn, - archive_root=archive_root, - blob_root=archive_root / "blob", - raw_row_limit=None, - sample_limit=MAX_ATTACHMENT_SAMPLE_LIMIT, - raw_session_parser=raw_session_parser, - ) - return BlobReferenceClosurePlan( - raw_candidates=tuple(raw_candidates), - attachment_candidates=tuple( - candidate for candidate in attachment_plan.eligible if candidate.attachment_id in acquired_attachment_ids - ), - blockers=tuple( - raw_blockers - + [ - blocker - for blocker in _attachment_blockers(attachment_plan) - if blocker.object_id in acquired_attachment_ids - ] - ), - raw_rows_scanned=attachment_plan.raw_rows_scanned, - raw_rows_total=raw_total, - attachment_orphan_count=len(acquired_attachment_ids), - attachment_blockers_sampled=attachment_plan.unrecoverable_samples_truncated, - ) - - -def plan_blob_reference_closure( - archive_root: Path, - *, - sample_limit: int = 30, - raw_session_parser: RawSessionParser | None = None, -) -> BlobReferenceClosurePlan: - """Build a complete, read-only plan from durable source and index evidence.""" - source_db = archive_root / "source.db" - index_db = archive_root / "index.db" - if not source_db.exists() or not index_db.exists(): - raise FileNotFoundError("blob-reference closure requires source.db and index.db") - source_conn = _open_ro(source_db) - index_conn = _open_ro(index_db) - try: - return _plan_connections( - index_conn, - source_conn, - archive_root=archive_root, - sample_limit=sample_limit, - raw_session_parser=raw_session_parser, - ) - finally: - index_conn.close() - source_conn.close() - - -def _plan_digest(plan: BlobReferenceClosurePlan) -> str: - attachment_payload: list[dict[str, object]] = [] - for attachment in plan.attachment_candidates: - attachment_payload.append( - { - "attachment_id": attachment.attachment_id, - "session_id": attachment.session_id, - "message_id": attachment.message_id, - "position": attachment.position, - "upload_origin": attachment.upload_origin, - "direction": attachment.direction, - "producer_ref": attachment.producer_ref, - "source_url": attachment.source_url, - "caption": attachment.caption, - "raw_id": attachment.raw_id, - "native_ids": attachment.native_ids, - } - ) - payload = { - "raw": [candidate.to_dict() for candidate in plan.raw_candidates], - "attachments": attachment_payload, - } - return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() - - -def _write_receipt(path: Path, *, archive_root: Path, plan: BlobReferenceClosurePlan, backup_manifest: Path) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - if path.exists(): - raise BlobReferenceClosureError(f"receipt already exists: {path}") - with path.open("x", encoding="utf-8") as handle: - json.dump( - { - "kind": "blob_reference_closure", - "tool_version": TOOL_VERSION, - "phase": "prepared", - "archive_root": str(archive_root), - "backup_manifest": str(backup_manifest), - "prepared_at_ms": int(time.time() * 1000), - "plan_digest": _plan_digest(plan), - "plan": plan.to_dict(), - }, - handle, - sort_keys=True, - ) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - _fsync_receipt_directory(path) - - -def _append_receipt(path: Path, phase: str, **extra: object) -> None: - with path.open("a", encoding="utf-8") as handle: - json.dump({"kind": "blob_reference_closure", "phase": phase, **extra}, handle, sort_keys=True) - handle.write("\n") - handle.flush() - os.fsync(handle.fileno()) - _fsync_receipt_directory(path) - - -def _fsync_receipt_directory(path: Path) -> None: - """Durably publish a newly created or extended receipt directory entry.""" - directory_fd = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) - - -def _validate_backups(backup_manifest: Path, source_conn: sqlite3.Connection, index_conn: sqlite3.Connection) -> None: - validate_migration_backup_manifest(backup_manifest, ArchiveTier.SOURCE, connection=source_conn) - validate_backup_manifest_covers_derived_tier(backup_manifest, ArchiveTier.INDEX, connection=index_conn) - - -def reconcile_blob_reference_closure( - archive_root: Path, - *, - backup_manifest: Path | None = None, - receipt_path: Path | None = None, - dry_run: bool = True, - sample_limit: int = 30, - raw_session_parser: RawSessionParser | None = None, -) -> BlobReferenceClosureReport: - """Plan closure repair, or add only deterministic exact references. - - Apply is offline, backup-gated, and additive. It never deletes or replaces - an existing reference. Attachment ownership is accepted only when a full - raw reparse reproduces the attachment identity and its message exists in - the current index. - """ - if dry_run: - return BlobReferenceClosureReport( - archive_root=str(archive_root), - dry_run=True, - applied=False, - plan=plan_blob_reference_closure( - archive_root, - sample_limit=sample_limit, - raw_session_parser=raw_session_parser, - ), - ) - if backup_manifest is None: - raise BlobReferenceClosureError("apply requires a verified backup manifest covering source.db and index.db") - if receipt_path is None: - raise BlobReferenceClosureError("apply requires an explicit receipt path") - if reason := offline_maintenance_block_reason(_offline_config(archive_root), active=True, dry_run=False): - raise BlobReferenceClosureError(reason) - - source_db = archive_root / "source.db" - index_db = archive_root / "index.db" - source_conn = sqlite3.connect(source_db) - index_conn: sqlite3.Connection | None = sqlite3.connect(index_db) - assert index_conn is not None - source_conn.execute("PRAGMA foreign_keys = ON") - index_conn.execute("PRAGMA foreign_keys = ON") - plan: BlobReferenceClosurePlan | None = None - source_repaired = 0 - attachment_repaired = 0 - prepared = False - committed = False - attached_index = False - try: - try: - assert index_conn is not None - _validate_backups(backup_manifest, source_conn, index_conn) - plan = _plan_connections( - index_conn, - source_conn, - archive_root=archive_root, - sample_limit=sample_limit, - raw_session_parser=raw_session_parser, - ) - _write_receipt(receipt_path, archive_root=archive_root, plan=plan, backup_manifest=backup_manifest) - prepared = True - - # A single connection and attached index database give SQLite one - # transaction boundary for both tiers. Planning and backup checks - # happen before ATTACH, so every conflict is known before either - # tier is mutated. - index_conn.close() - index_conn = None - source_conn.execute("ATTACH DATABASE ? AS index_tier", (str(index_db),)) - attached_index = True - source_conn.execute("BEGIN IMMEDIATE") - for candidate in plan.raw_candidates: - source_conn.execute( - """ - INSERT INTO blob_refs ( - blob_hash, ref_id, ref_type, source_path, size_bytes, acquired_at_ms - ) VALUES (?, ?, 'raw_payload', ?, ?, ?) - """, - ( - candidate.blob_hash, - candidate.raw_id, - candidate.source_path, - candidate.blob_size, - candidate.acquired_at_ms, - ), - ) - exact = source_conn.execute( - """ - SELECT COUNT(*) FROM blob_refs b - JOIN raw_sessions r ON r.raw_id = b.ref_id AND r.blob_hash = b.blob_hash - WHERE b.ref_type = 'raw_payload' AND b.ref_id = ? - """, - (candidate.raw_id,), - ).fetchone()[0] - if exact != 1: - raise BlobReferenceClosureError(f"raw exact-match check failed after insert: {candidate.raw_id}") - source_repaired += 1 - for attachment_candidate in plan.attachment_candidates: - source_conn.execute( - """ - INSERT INTO index_tier.attachment_refs ( - attachment_id, session_id, message_id, position, upload_origin, direction, producer_ref, source_url, caption - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - attachment_candidate.attachment_id, - attachment_candidate.session_id, - attachment_candidate.message_id, - attachment_candidate.position, - attachment_candidate.upload_origin, - attachment_candidate.direction, - attachment_candidate.producer_ref, - attachment_candidate.source_url, - attachment_candidate.caption, - ), - ) - source_conn.execute( - """ - UPDATE index_tier.attachments - SET ref_count = ( - SELECT COUNT(*) FROM index_tier.attachment_refs - WHERE index_tier.attachment_refs.attachment_id = index_tier.attachments.attachment_id - ) - WHERE attachment_id = ? - """, - (attachment_candidate.attachment_id,), - ) - for id_kind, native_id in attachment_candidate.native_ids: - source_conn.execute( - """ - INSERT OR IGNORE INTO index_tier.attachment_native_ids (ref_id, id_kind, native_id) - VALUES (?, ?, ?) - """, - ( - f"{attachment_candidate.message_id}:attachment:{attachment_candidate.position}", - id_kind, - native_id, - ), - ) - exact = source_conn.execute( - "SELECT COUNT(*) FROM index_tier.attachment_refs WHERE attachment_id = ?", - (attachment_candidate.attachment_id,), - ).fetchone()[0] - if exact < 1: - raise BlobReferenceClosureError( - f"attachment reference check failed after insert: {attachment_candidate.attachment_id}" - ) - attachment_repaired += 1 - source_conn.commit() - committed = True - _append_receipt(receipt_path, "source_committed", repaired_count=source_repaired) - _append_receipt(receipt_path, "index_committed", repaired_count=attachment_repaired) - _append_receipt( - receipt_path, - "committed", - raw_repaired_count=source_repaired, - attachment_repaired_count=attachment_repaired, - ) - except Exception as exc: - if source_conn.in_transaction: - source_conn.rollback() - if prepared: - with suppress(OSError): - _append_receipt( - receipt_path, - "committed_receipt_incomplete" if committed else "aborted", - error=str(exc), - ) - raise - finally: - if attached_index: - with suppress(sqlite3.Error): - source_conn.execute("DETACH DATABASE index_tier") - if index_conn is not None: - index_conn.close() - source_conn.close() - - assert plan is not None - return BlobReferenceClosureReport( - archive_root=str(archive_root), - dry_run=False, - applied=True, - plan=plan, - raw_repaired_count=source_repaired, - attachment_repaired_count=attachment_repaired, - backup_manifest=backup_manifest, - receipt_path=receipt_path, - ) - - def closure_counts(source_conn: sqlite3.Connection, index_conn: sqlite3.Connection) -> dict[str, int]: """Return exact structural closure counts without parsing or mutation.""" raw_missing = int( @@ -573,15 +46,4 @@ def closure_counts(source_conn: sqlite3.Connection, index_conn: sqlite3.Connecti return {"raw_missing_exact_count": raw_missing, "acquired_attachment_missing_ref_count": attachment_missing} -__all__ = [ - "BlobReferenceBlockerKind", - "BlobReferenceClosureBlocker", - "BlobReferenceClosureError", - "BlobReferenceClosurePlan", - "BlobReferenceClosureReport", - "RawBlobReferenceCandidate", - "closure_counts", - "plan_blob_reference_closure", - "raw_reference_closure_predicate", - "reconcile_blob_reference_closure", -] +__all__ = ["closure_counts", "raw_reference_closure_predicate"] diff --git a/polylogue/maintenance/cost_backfill.py b/polylogue/maintenance/cost_backfill.py deleted file mode 100644 index 7eb27ef42e..0000000000 --- a/polylogue/maintenance/cost_backfill.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Cost-row backfill helper (#1140). - -Identifies stale ``session_profiles`` rows that pre-date the cost basis -split (#1136). Those rows have a populated ``total_cost_usd`` column but -were materialized before per-basis ``provider_reported_usd``, -``api_equivalent_usd``, ``catalog_priced_usd``, etc. were introduced as -typed payload fields. The current substrate keeps those basis fields in -the ``evidence_payload`` / ``inference_payload`` JSON envelopes via -``CostBasisPayload``; stale rows therefore have ``total_cost_usd`` set -but their evidence payload lacks the typed basis split. - -The backfill is one-shot, planner-driven, and idempotent: - -1. **Detect** single-basis rows via ``find_single_basis_cost_rows()`` — pure-SQL - read against ``session_profiles`` selecting rows whose - ``cost_provenance`` is the untyped ``"unknown"`` marker (or other - pre-basis marker) and ``total_cost_usd > 0``. -2. **Tag** them with the typed source label ``single-basis-cost`` - so downstream surfaces can render "why" instead of an opaque zero. - The tag flows through ``ArchiveInsightProvenance`` on the rebuilt - insight. -3. **Schedule** a rebuild via :func:`plan_cost_backfill` — returns a - :class:`BackfillOperation` targeting the canonical session-profile - rebuild target. The maintenance planner already owns the actual - rebuild path; this helper is the bridge that classifies the work and - reasons about its scope. - -The helper is intentionally pure-function-shaped (the actual DB writes -go through :func:`polylogue.maintenance.planner.execute_backfill`), so -it stays exercisable in unit tests without spinning up the full -maintenance executor. -""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass -from typing import Protocol - -from polylogue.core.enums import OperationStatus -from polylogue.core.json import json_document -from polylogue.maintenance.invalidation import InvalidationReason -from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - MaintenanceScope, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter - -__all__ = [ - "SINGLE_BASIS_COST_SOURCE", - "SINGLE_BASIS_COST_PROVENANCE_MARKERS", - "SESSION_PROFILES_REBUILD_TARGET", - "SingleBasisCostRow", - "SingleBasisCostRowReader", - "find_single_basis_cost_rows", - "plan_cost_backfill", -] - - -#: Source tag attached to stale single-basis cost rows after classification. -#: Surfaces should render this verbatim so users see "why" a single-basis -#: total is in play instead of a typed split. -SINGLE_BASIS_COST_SOURCE = "single-basis-cost" - - -#: Cost-provenance values treated as stale single-basis rows. These are -#: the values that pre-date the #1136 basis split. ``"unknown"`` is the -#: default on session_profile rows whose materializer never set a basis -#: provenance. ``"mixed"`` and ``"provider_reported"`` are post-#1136 and -#: are explicitly excluded. -SINGLE_BASIS_COST_PROVENANCE_MARKERS: frozenset[str] = frozenset({"unknown", ""}) - - -#: Canonical session-profile rebuild target name. The actual target spec -#: lives in :mod:`polylogue.maintenance.targets`; the backfill delegates -#: rebuild execution there rather than duplicating the rebuild logic. -SESSION_PROFILES_REBUILD_TARGET = "session_profiles" - - -@dataclass(frozen=True, slots=True) -class SingleBasisCostRow: - """One stale single-basis session-profile row identified for backfill.""" - - session_id: str - source_name: str - total_cost_usd: float - cost_provenance: str - - -class SingleBasisCostRowReader(Protocol): - """Read seam for :func:`find_single_basis_cost_rows`. - - A reader returns the rows in ``session_profiles`` whose cost columns - look like the pre-#1136 single-basis shape. Implementations are - expected to issue a single SELECT against the archive SQLite - database; the indirection lets unit tests substitute an in-memory - fixture without spinning up a full archive. - """ - - def __call__( - self, - *, - provenance_markers: frozenset[str] = SINGLE_BASIS_COST_PROVENANCE_MARKERS, - min_total_usd: float = 0.0, - ) -> tuple[SingleBasisCostRow, ...]: ... - - -def find_single_basis_cost_rows( - reader: SingleBasisCostRowReader, - *, - provenance_markers: frozenset[str] = SINGLE_BASIS_COST_PROVENANCE_MARKERS, - min_total_usd: float = 0.0, -) -> tuple[SingleBasisCostRow, ...]: - """Return stale single-basis session-profile rows from ``reader``. - - A row is stale when its ``cost_provenance`` matches one of - ``provenance_markers`` AND its ``total_cost_usd`` is strictly greater - than ``min_total_usd``. Both conditions are necessary: rows whose - ``total_cost_usd`` is zero have nothing to backfill (the basis split - would be all zeros), and rows whose provenance is already typed - (``provider_reported``, ``mixed``, etc.) carry the split via the - evidence payload and are intentionally excluded. - - The function is a thin filter over ``reader`` so callers can supply - either a live SQLite-backed reader or an in-memory fixture without - duplicating the classification logic. - """ - rows = reader(provenance_markers=provenance_markers, min_total_usd=min_total_usd) - return tuple( - row for row in rows if row.cost_provenance in provenance_markers and row.total_cost_usd > min_total_usd - ) - - -def plan_cost_backfill( - rows: tuple[SingleBasisCostRow, ...], - *, - dry_run: bool = True, -) -> BackfillOperation: - """Plan a backfill operation that rebuilds stale single-basis session profiles. - - The returned :class:`BackfillOperation` targets the canonical - ``session_profiles`` rebuild path. Executing it through - :func:`polylogue.maintenance.planner.execute_backfill` causes those - rows to be re-materialized with the #1136 basis split populated and - ``ArchiveInsightProvenance`` tagged with :data:`SINGLE_BASIS_COST_SOURCE`. - - Returns a ``PENDING`` operation when ``dry_run`` is true (the default - — surfaces should preview before executing). Pass ``dry_run=False`` - in non-test callers to let the operation be handed straight to the - executor. - - The operation carries a typed :class:`InvalidationReason` of - ``STALE_MATERIALIZER_VERSION`` because the materializer that wrote - these rows pre-dates the basis-split addition. - """ - operation_id = str(uuid.uuid4()) - scope = MaintenanceScope( - targets=(SESSION_PROFILES_REBUILD_TARGET,), - filter=MaintenanceScopeFilter( - session_ids=tuple(row.session_id for row in rows) or None, - ), - ) - affected = len(rows) - estimated_time_s = affected / 50.0 if affected > 0 else 0.0 - results = [ - json_document( - { - "session_id": row.session_id, - "source_name": row.source_name, - "total_cost_usd": row.total_cost_usd, - "cost_provenance": row.cost_provenance, - "source": SINGLE_BASIS_COST_SOURCE, - } - ) - for row in rows - ] - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=(SESSION_PROFILES_REBUILD_TARGET,), - status=OperationStatus.PENDING, - affected_rows=affected, - estimated_time_s=estimated_time_s, - results=results, - scope=scope, - reason=InvalidationReason.STALE_MATERIALIZER_VERSION, - ) diff --git a/polylogue/maintenance/envelope.py b/polylogue/maintenance/envelope.py deleted file mode 100644 index c910be7050..0000000000 --- a/polylogue/maintenance/envelope.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Shared maintenance operation envelope across CLI, daemon HTTP, and MCP. - -This module owns the **single typed envelope** that every maintenance -surface — CLI ``polylogue ops maintenance plan/run``, daemon HTTP -``/api/maintenance/plan`` and ``/api/maintenance/run``, and MCP -``maintenance_preview`` / ``maintenance_execute`` — emits as its result -shape. It exists so that an operator (or a script) can call any of the -three surfaces and parse the same JSON. - -The envelope is a structural superset of -:meth:`polylogue.maintenance.planner.BackfillOperation.to_dict` plus a -small set of surface-agnostic metadata: - -* ``origin`` — which surface produced this envelope (``"cli"``, - ``"daemon"``, ``"mcp"``). Useful for diagnostics and for filtering - operation history without re-deriving the surface from a process - name. -* ``mode`` — ``"preview"`` (read-only inventory; no mutations) or - ``"execute"`` (an actual or dry-run repair pass). Surfaces always - populate this so callers do not have to infer mode from the kind + - status fields. - -The :func:`envelope_from_operation` factory is the only public way to -build an envelope. It refuses unknown ``origin`` / ``mode`` strings -and copies every planner field into a frozen Pydantic model so callers -cannot accidentally mutate the result. - -Parity across surfaces is pinned by -``tests/unit/maintenance/test_envelope_contracts.py``. -""" - -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import ConfigDict - -from polylogue.core.json import JSONDocument, json_document -from polylogue.maintenance.planner import ( - BackfillOperation, - BoundedFailureSamples, - FailureSample, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter, unsupported_scope_dimensions -from polylogue.surfaces.payloads import SurfacePayloadModel - -#: Allowed values for the ``origin`` envelope field. -EnvelopeOrigin = Literal["cli", "daemon", "mcp"] - -#: Allowed values for the ``mode`` envelope field. -EnvelopeMode = Literal["preview", "execute"] - - -class MaintenanceFailureSamplePayload(SurfacePayloadModel): - """One bounded failure sample, mirrored from :class:`FailureSample`.""" - - kind: str - locator: str - message: str - - @classmethod - def from_sample(cls, sample: FailureSample) -> MaintenanceFailureSamplePayload: - return cls(kind=sample.kind, locator=sample.locator, message=sample.message) - - -class MaintenanceFailureSamplesPayload(SurfacePayloadModel): - """Bounded failure-sample envelope, mirrored from :class:`BoundedFailureSamples`.""" - - samples: tuple[MaintenanceFailureSamplePayload, ...] = () - truncated: bool = False - - @classmethod - def from_bounded(cls, bounded: BoundedFailureSamples) -> MaintenanceFailureSamplesPayload: - return cls( - samples=tuple(MaintenanceFailureSamplePayload.from_sample(s) for s in bounded.samples), - truncated=bounded.truncated, - ) - - -class MaintenanceScopePayload(SurfacePayloadModel): - """Typed scope view: target ids + typed scope filter. - - ``filter`` is the serialized form of - :class:`polylogue.maintenance.scope.MaintenanceScopeFilter` — - a dict of named scope dimensions (``session_ids``, - ``origin``, ``source_family``, ``source_root``, - ``time_range``, ``failure_kind``, ``parser_version``). It is typed - as ``dict[str, Any]`` at the - Pydantic boundary so the recursive ``JSONValue`` alias does not - need a manual rebuild; the typed contract is pinned by - :class:`MaintenanceScopeFilter` itself. - """ - - targets: tuple[str, ...] - filter: dict[str, Any] - unsupported_dimensions: tuple[str, ...] = () - - -class MaintenanceOperationEnvelope(SurfacePayloadModel): - """Surface-agnostic envelope for one maintenance operation. - - Carries everything in :class:`BackfillOperation.to_dict` plus an - ``origin`` tag and a ``mode`` discriminator so the same shape is - emitted by CLI, daemon HTTP, and MCP. The envelope is frozen and - forbids extra fields — adding a field is a deliberate API change, - not an accidental drift. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - operation_id: str - kind: str - mode: EnvelopeMode - origin: EnvelopeOrigin - status: str - targets: tuple[str, ...] - scope: MaintenanceScopePayload - progress: float - started_at: str | None - completed_at: str | None - error: str | None - affected_rows: int - estimated_time_s: float - results: tuple[dict[str, Any], ...] - reason: str | None - resume_cursor: str | None - failure_samples: MaintenanceFailureSamplesPayload - metrics: dict[str, float] - - def to_dict(self) -> JSONDocument: - """Return the envelope as a JSON-shaped dict. - - ``mode="json"`` ensures tuples and Pydantic types are reduced - to JSON primitives so the result is byte-stable across - surfaces. - """ - return json_document(self.model_dump(mode="json")) - - -def envelope_from_operation( - operation: BackfillOperation, - *, - origin: EnvelopeOrigin, - mode: EnvelopeMode, -) -> MaintenanceOperationEnvelope: - """Wrap a planner :class:`BackfillOperation` in the shared envelope. - - Parameters - ---------- - operation: - Planner result from :func:`polylogue.maintenance.planner.preview_backfill`, - :func:`polylogue.maintenance.planner.execute_backfill`, or - :func:`polylogue.maintenance.replay.execute_replay`. - origin: - Surface that produced this envelope. ``"cli"``, ``"daemon"``, - or ``"mcp"``. - mode: - ``"preview"`` for read-only inventory, ``"execute"`` for an - actual or dry-run repair pass. - """ - scope = operation.scope - if scope is not None: - scope_targets = tuple(scope.targets) - scope_filter_dict = scope.filter.to_dict() - else: - scope_targets = tuple(operation.targets) - scope_filter_dict = MaintenanceScopeFilter().to_dict() - scope_payload = MaintenanceScopePayload( - targets=scope_targets, - filter=scope_filter_dict, - unsupported_dimensions=( - tuple( - dimension - for dimension in ( - "session_ids", - "origin", - "source_family", - "source_root", - "time_range", - "failure_kind", - "parser_version", - ) - if any( - dimension in unsupported_scope_dimensions(scope.filter, target=target) for target in scope_targets - ) - ) - if scope is not None - else () - ), - ) - return MaintenanceOperationEnvelope( - operation_id=operation.operation_id, - kind=operation.kind.value, - mode=mode, - origin=origin, - status=operation.status.value, - targets=tuple(operation.targets), - scope=scope_payload, - progress=operation.progress, - started_at=operation.started_at, - completed_at=operation.completed_at, - error=operation.error, - affected_rows=operation.affected_rows, - estimated_time_s=operation.estimated_time_s, - results=tuple(operation.results), - reason=(operation.reason.value if operation.reason is not None else None), - resume_cursor=operation.resume_cursor, - failure_samples=MaintenanceFailureSamplesPayload.from_bounded(operation.failure_samples), - metrics=dict(operation.metrics), - ) - - -def envelope_keys() -> frozenset[str]: - """Return the canonical set of top-level envelope keys. - - Used by the parity test to assert that surfaces agree byte-for-byte - on the envelope shape. - """ - return frozenset(MaintenanceOperationEnvelope.model_fields.keys()) - - -__all__ = [ - "EnvelopeMode", - "EnvelopeOrigin", - "MaintenanceFailureSamplePayload", - "MaintenanceFailureSamplesPayload", - "MaintenanceOperationEnvelope", - "MaintenanceScopePayload", - "envelope_from_operation", - "envelope_keys", -] diff --git a/polylogue/maintenance/failure_routing.py b/polylogue/maintenance/failure_routing.py deleted file mode 100644 index 69a9ac5d38..0000000000 --- a/polylogue/maintenance/failure_routing.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Route per-record maintenance failures to the daemon raw-failure surface. - -This module is the bridge between :mod:`polylogue.maintenance.replay` -and the daemon raw-failure surface implemented by -:func:`polylogue.daemon.status._raw_failure_info` and -:func:`polylogue.daemon.health._check_raw_failures_medium` (#844). - -Why a separate substrate -======================== - -Maintenance failures are operationally distinct from ingest failures: - -* ingest failures land in ``raw_sessions.parse_error`` / - ``raw_sessions.validation_status`` because they describe the - state of a *row* in the archive — the raw acquisition exists but - could not be parsed or validated; -* maintenance failures describe an *attempt* to repair or replay - archive state. They do not always correspond to a single - ``raw_sessions`` row (e.g. a session-insight rebuild that fails - on one ``session_id``), and they carry a maintenance-specific - ``operation_id`` that should be visible to operators. - -The two #1198 design alternatives were: - -A. Extend ``raw_sessions.validation_status`` to a third value - ``"maintenance_failed"``. Reuses existing daemon read paths but - conflates two semantically different failure surfaces and forces a - schema-version bump for every existing archive. -B. Persist routed failures to a small append-only JSONL file under - ``/.maintenance-state/failures.jsonl`` and have the - daemon read it alongside the SQL raw-failure query. - -We chose (B). The maintenance state directory already exists -(``polylogue/maintenance/replay.py:_STATE_DIRNAME``) and is the -canonical location for resume cursors and operation snapshots. A -single JSONL file fits cleanly there and avoids any schema rebuild, -which is important because Polylogue intentionally has no in-place schema upgrade -chain — a schema bump forces every operator to rebuild their archive. - -The file is **bounded**: writes append, and reads cap the returned -sample list at :data:`MAINTENANCE_FAILURE_SAMPLE_LIMIT`. A separate -maintenance task may rotate or truncate the file; the daemon surface -treats older entries as background context, not as a queue. - -Redaction -========= - -Every routed sample passes through redaction at construction time: - -* ``message`` is truncated to :data:`MAX_MESSAGE_LEN` characters; -* absolute Unix paths in ``message`` are replaced with ``[redacted]`` - using the same heuristic as :class:`RawFailureSample` so the daemon - surface never exposes operator filesystem layout; -* ``locator`` is preserved verbatim because it is already a typed, - short identifier (e.g. ``target:session_insights``); a separate - pass strips absolute path segments out of any trailing ``:path`` - segment for the same reason. - -Raw message bodies and blob bytes are never routed through this -surface — only the bounded ``FailureSample.message`` from the planner -envelope is persisted. -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Final - -from polylogue.core.json import JSONDocument, dumps, json_document, loads -from polylogue.logging import get_logger -from polylogue.maintenance.planner import FailureSample - -logger = get_logger(__name__) - -#: Maximum stored characters in a routed ``message`` field. Keeps the -#: JSONL file size predictable even when a repair surfaces a long -#: traceback excerpt. -MAX_MESSAGE_LEN: Final[int] = 500 - -#: Cap on routed samples returned by :func:`read_maintenance_failures`. -#: Mirrors the per-failure surface limit used by the daemon status -#: payload so the two sources of failure samples are comparable. -MAINTENANCE_FAILURE_SAMPLE_LIMIT: Final[int] = 50 - -#: Subdirectory under ```` that holds maintenance state -#: files. Matches ``polylogue.maintenance.replay._STATE_DIRNAME`` so -#: routed failures share a directory with operation snapshots and -#: resume cursors. Kept in sync deliberately; an import from the -#: ``replay`` module would create a cycle. -_STATE_DIRNAME: Final[str] = ".maintenance-state" - -#: Name of the JSONL file under the state directory. One JSON object -#: per line, newest entries appended at the end. Designed so a partial -#: write (process kill mid-line) only loses the final entry — every -#: prior entry is parseable on its own. -_FAILURE_FILE_NAME: Final[str] = "failures.jsonl" - -#: Matches candidate absolute Unix paths in the same shape as -#: :func:`polylogue.daemon.status._PATH_REDACTION_RE`. We keep the -#: definition local so :mod:`polylogue.maintenance` does not need to -#: import from :mod:`polylogue.daemon` (and create a layering cycle). -_PATH_REDACTION_RE: Final[re.Pattern[str]] = re.compile(r"/(?:[a-zA-Z0-9._\-]+/)*[a-zA-Z0-9._\-]+") - - -def _redact_paths(text: str) -> str: - """Replace absolute Unix paths in ``text`` with ``[redacted]``. - - The redaction strategy mirrors - :func:`polylogue.daemon.status.RawFailureSample._redact_file_paths` - so daemon status text and routed maintenance text remain - consistent. URL path segments (preceded by alphanumerics, dots, - colons, or containing ``://``) are preserved. - """ - - def _replace(m: re.Match[str]) -> str: - start = m.start() - if start == 0: - return "[redacted]" - prev = text[start - 1] - if prev.isalnum() or prev in (".", ":"): - return m.group(0) - prefix = text[max(0, start - 16) : start + 1] - if "://" in prefix: - return m.group(0) - return "[redacted]" - - return _PATH_REDACTION_RE.sub(_replace, text) - - -def _redact_locator(locator: str) -> str: - """Redact absolute paths embedded in a ``target:...`` locator string. - - Some locators emitted by :mod:`polylogue.maintenance.replay` embed - an absolute path in a trailing segment. The redactor strips any - absolute path unconditionally for the locator form, because a - structured colon-separated suffix would otherwise look like a URL - host separator and prevent path-aware redaction. - """ - # Always replace the first absolute path token in the locator - # suffix. The URL/host heuristic used for free-form messages is - # inappropriate here because the locator is a structured colon- - # separated identifier. - return _PATH_REDACTION_RE.sub("[redacted]", locator) - - -@dataclass(frozen=True) -class MaintenanceFailureRecord: - """One routed maintenance failure persisted to the JSONL surface. - - Fields are a superset of :class:`FailureSample` plus the - operation identity needed by the daemon raw-failure surface so it - can attribute failures back to the originating replay run. - """ - - operation_id: str - target: str - kind: str - locator: str - message: str - routed_at: str - - def to_dict(self) -> JSONDocument: - return json_document( - { - "operation_id": self.operation_id, - "target": self.target, - "kind": self.kind, - "locator": self.locator, - "message": self.message, - "routed_at": self.routed_at, - } - ) - - @classmethod - def from_dict(cls, data: object) -> MaintenanceFailureRecord | None: - if not isinstance(data, dict): - return None - try: - return cls( - operation_id=str(data.get("operation_id", "")), - target=str(data.get("target", "")), - kind=str(data.get("kind", "")), - locator=str(data.get("locator", "")), - message=str(data.get("message", "")), - routed_at=str(data.get("routed_at", "")), - ) - except (TypeError, ValueError): - return None - - -def _failure_file_path(archive_root: Path) -> Path: - """Return the canonical JSONL path under ``archive_root``.""" - return Path(archive_root) / _STATE_DIRNAME / _FAILURE_FILE_NAME - - -def _truncate_message(message: str) -> str: - if len(message) <= MAX_MESSAGE_LEN: - return message - return message[: MAX_MESSAGE_LEN - 3] + "..." - - -def route_failure_sample( - sample: FailureSample, - *, - operation_id: str, - archive_root: Path, - target: str | None = None, - now: datetime | None = None, -) -> MaintenanceFailureRecord: - """Persist one :class:`FailureSample` to the daemon-visible surface. - - Parameters - ---------- - sample: - The per-record failure from a replay or repair function. - operation_id: - Identity of the originating :class:`BackfillOperation` so the - daemon surface can attribute the failure back to a specific - replay run. - archive_root: - Archive root under which the JSONL file lives. Pass the value - from :func:`Config.archive_root` (or the test fixture) so the - write is scoped to the correct archive. - target: - Optional logical target name. When omitted, the function - infers it from the locator prefix (``target::...``). - now: - Optional clock injection point used by tests; defaults to - ``datetime.now(UTC)``. - - Returns - ------- - MaintenanceFailureRecord - The persisted record (also returned so callers can log it). - """ - - inferred_target = target - if inferred_target is None: - inferred_target = _infer_target_from_locator(sample.locator) - - routed_at = (now or datetime.now(UTC)).isoformat() - record = MaintenanceFailureRecord( - operation_id=operation_id, - target=inferred_target, - kind=sample.kind, - locator=_redact_locator(sample.locator), - message=_redact_paths(_truncate_message(sample.message)), - routed_at=routed_at, - ) - - path = _failure_file_path(archive_root) - try: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as handle: - handle.write(dumps(record.to_dict())) - handle.write("\n") - except OSError as exc: - # Routing must never abort the replay loop. Log and move on; - # the failure stays in the in-memory ``BoundedFailureSamples`` - # envelope on the returned ``BackfillOperation``. - logger.warning( - "maintenance_failure_route_failed", - operation_id=operation_id, - target=inferred_target, - error=str(exc), - ) - return record - - -def _infer_target_from_locator(locator: str) -> str: - """Pull the target token out of a ``target:...`` locator.""" - if not locator.startswith("target:"): - return "unknown" - suffix = locator[len("target:") :] - head = suffix.split(":", 1)[0] - return head or "unknown" - - -def read_maintenance_failures( - archive_root: Path, - *, - limit: int = MAINTENANCE_FAILURE_SAMPLE_LIMIT, -) -> list[MaintenanceFailureRecord]: - """Read the routed failures JSONL and return the most recent ``limit``. - - Lines that fail to parse are skipped with a warning; a partial - write at the tail of the file (process killed mid-line) does not - prevent earlier records from being read. - - Returns an empty list when the file does not exist, which is the - expected steady state for an archive with no maintenance - failures. - """ - - path = _failure_file_path(archive_root) - if not path.exists(): - return [] - records: list[MaintenanceFailureRecord] = [] - try: - with path.open("r", encoding="utf-8") as handle: - for line in handle: - stripped = line.strip() - if not stripped: - continue - try: - payload = loads(stripped) - except (ValueError, TypeError): - logger.warning( - "maintenance_failure_unparseable_line", - path=str(path), - ) - continue - rec = MaintenanceFailureRecord.from_dict(payload) - if rec is not None: - records.append(rec) - except OSError as exc: - logger.warning( - "maintenance_failure_read_failed", - path=str(path), - error=str(exc), - ) - # Keep the historical list-returning API fail-soft for callers that - # only need samples. Status callers use the typed helper below so an - # unreadable ledger cannot be mistaken for an empty one. - return [] - if len(records) <= limit: - return records - return records[-limit:] - - -def read_maintenance_failures_with_error( - archive_root: Path, - *, - limit: int = MAINTENANCE_FAILURE_SAMPLE_LIMIT, -) -> tuple[list[MaintenanceFailureRecord], str | None]: - """Read maintenance evidence while preserving an I/O refusal. - - Malformed lines remain non-fatal (the file is still readable), but an - unreadable ledger is distinct from a ledger with no failure records. - """ - path = _failure_file_path(archive_root) - if not path.exists(): - return [], None - records: list[MaintenanceFailureRecord] = [] - try: - with path.open("r", encoding="utf-8") as handle: - for line in handle: - stripped = line.strip() - if not stripped: - continue - try: - payload = loads(stripped) - except (ValueError, TypeError): - logger.warning("maintenance_failure_unparseable_line", path=str(path)) - continue - rec = MaintenanceFailureRecord.from_dict(payload) - if rec is not None: - records.append(rec) - except OSError as exc: - logger.warning("maintenance_failure_read_failed", path=str(path), error=str(exc)) - return [], str(exc) - return (records if len(records) <= limit else records[-limit:]), None - - -def resolve_maintenance_failures( - archive_root: Path, - *, - target: str, - kinds: tuple[str, ...] = (), -) -> int: - """Remove routed failure records that a later successful run supersedes. - - Routed failures are append-only evidence of failed attempts, but the - daemon health surface is an active backlog signal. Once a target has - been successfully replayed, older failures for that target should no - longer keep health red. ``kinds`` narrows resolution when only a - class of failure was proven stale, such as a dry-run proving an - ``UnsupportedReplayTargetError`` target is now wired. - """ - - path = _failure_file_path(archive_root) - if not path.exists(): - return 0 - kept: list[MaintenanceFailureRecord] = [] - removed = 0 - kind_filter = set(kinds) - try: - with path.open("r", encoding="utf-8") as handle: - for line in handle: - stripped = line.strip() - if not stripped: - continue - try: - payload = loads(stripped) - except (ValueError, TypeError): - logger.warning( - "maintenance_failure_unparseable_line", - path=str(path), - ) - continue - rec = MaintenanceFailureRecord.from_dict(payload) - if rec is None: - continue - if rec.target == target and (not kind_filter or rec.kind in kind_filter): - removed += 1 - continue - kept.append(rec) - if removed == 0: - return 0 - if kept: - with path.open("w", encoding="utf-8") as handle: - for rec in kept: - handle.write(dumps(rec.to_dict())) - handle.write("\n") - else: - path.unlink(missing_ok=True) - except OSError as exc: - logger.warning( - "maintenance_failure_resolve_failed", - path=str(path), - target=target, - error=str(exc), - ) - return 0 - return removed - - -def count_maintenance_failures(archive_root: Path) -> int: - """Return the total number of routed maintenance failures on disk. - - The count is used by :func:`polylogue.daemon.health._check_raw_failures_medium` - to escalate raw-failure alerts; cheap to call even when the file - is moderately large because we only count non-empty lines. - """ - - path = _failure_file_path(archive_root) - if not path.exists(): - return 0 - try: - with path.open("r", encoding="utf-8") as handle: - return sum(1 for line in handle if line.strip()) - except OSError as exc: - logger.warning( - "maintenance_failure_count_failed", - path=str(path), - error=str(exc), - ) - return 0 - - -def clear_maintenance_failures(archive_root: Path) -> None: - """Remove the routed failures JSONL file. - - Used by tests and by operators who have addressed a backlog of - failed maintenance attempts and want the daemon raw-failure surface - to reset its maintenance bucket. - """ - - path = _failure_file_path(archive_root) - try: - path.unlink(missing_ok=True) - except OSError as exc: - logger.warning( - "maintenance_failure_clear_failed", - path=str(path), - error=str(exc), - ) - - -__all__ = [ - "MAINTENANCE_FAILURE_SAMPLE_LIMIT", - "MAX_MESSAGE_LEN", - "MaintenanceFailureRecord", - "clear_maintenance_failures", - "count_maintenance_failures", - "read_maintenance_failures", - "resolve_maintenance_failures", - "route_failure_sample", -] diff --git a/polylogue/maintenance/invalidation.py b/polylogue/maintenance/invalidation.py index e01041bb44..320e7ea1eb 100644 --- a/polylogue/maintenance/invalidation.py +++ b/polylogue/maintenance/invalidation.py @@ -1,12 +1,8 @@ -"""Typed invalidation reasons for maintenance planning. - -A derived read model can be stale for many distinct reasons; the -maintenance planner needs to surface *why* it scheduled work so that -operators and downstream tooling can reason about the resulting -:class:`~polylogue.maintenance.planner.BackfillOperation`. Until this -module landed, the planner only carried a free-form ``detail`` string -inherited from :class:`~polylogue.maintenance.models.DerivedModelStatus`, -which lost the structured reason at the surface boundary. +"""Typed invalidation reasons for derived read-model status. + +:class:`~polylogue.maintenance.models.DerivedModelStatus` carries one of +these so status surfaces report *why* a derived read model is stale, not +only a free-form detail string. The enum here is intentionally small and closed. New reasons must be added explicitly: callers persisting the value (logs, JSON envelopes, diff --git a/polylogue/maintenance/models.py b/polylogue/maintenance/models.py index 57a776b23b..c18c3f29cf 100644 --- a/polylogue/maintenance/models.py +++ b/polylogue/maintenance/models.py @@ -3,18 +3,11 @@ from __future__ import annotations from dataclasses import dataclass -from enum import Enum from polylogue.core.json import JSONDocument, json_document from polylogue.maintenance.invalidation import InvalidationReason -class MaintenanceCategory(str, Enum): - DERIVED_REPAIR = "derived_repair" - ARCHIVE_CLEANUP = "archive_cleanup" - SOURCE_INGEST = "source_ingest" - - @dataclass(frozen=True) class DerivedModelStatus: name: str @@ -55,4 +48,4 @@ def to_dict(self) -> JSONDocument: ) -__all__ = ["DerivedModelStatus", "MaintenanceCategory"] +__all__ = ["DerivedModelStatus"] diff --git a/polylogue/maintenance/planner.py b/polylogue/maintenance/planner.py deleted file mode 100644 index 8df62189d5..0000000000 --- a/polylogue/maintenance/planner.py +++ /dev/null @@ -1,764 +0,0 @@ -"""Typed maintenance planner contract. - -The planner owns the typed contract used by every maintenance / backfill -surface (CLI ``maintenance``, daemon HTTP, MCP, Python API). A -:class:`BackfillOperation` carries everything a caller needs to reason -about *one* maintenance attempt without having to peek into the repair -internals: - -* identity — ``operation_id`` plus a typed ``kind``; -* scope — a typed :class:`MaintenanceScope` (target names + optional - filter) instead of a bare tuple, so future surfaces can attach - session-id filters, time windows, source roots, etc. without - changing the signature again; -* reason — a typed :class:`~polylogue.maintenance.invalidation.InvalidationReason` - recording *why* the planner scheduled the work; -* progress — ``status``, ``progress``, ``started_at`` / ``completed_at``, - ``affected_rows``, ``estimated_time_s``; -* resumability — an opaque ``resume_cursor`` string that the executor - can hand back to itself on the next attempt; -* failures — a :class:`BoundedFailureSamples` envelope with at most - ``MAX_FAILURE_SAMPLES`` entries plus a ``truncated`` flag so the - unbounded raw failure list never leaks through; -* metrics — a free-form numeric dict (counters and timings) reported by - the executor. - -The shape is intentionally additive over the earlier scaffold so that -existing callers (CLI, daemon, MCP, API write surfaces, contract -tests) continue to work unchanged. -""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum -from typing import TYPE_CHECKING - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus - -if TYPE_CHECKING: - from polylogue.storage.repair import ArchiveDebtStatus -from polylogue.core.json import JSONDocument, json_document -from polylogue.logging import get_logger -from polylogue.maintenance.invalidation import InvalidationReason -from polylogue.maintenance.scope import MaintenanceScopeFilter, unsupported_scope_dimensions -from polylogue.maintenance.targets import ( - CLEANUP_TARGETS, - SAFE_REPAIR_TARGETS, - build_maintenance_target_catalog, -) - -logger = get_logger(__name__) - - -#: Hard cap on per-operation failure samples. Real executors must -#: truncate before populating :class:`BoundedFailureSamples`; the -#: envelope refuses to grow beyond this. -MAX_FAILURE_SAMPLES = 50 - - -def _coerce_float(value: object) -> float | None: - """Best-effort projection of a JSON value onto ``float`` (or ``None``). - - Used by :meth:`BackfillOperation.from_dict` to rehydrate numeric - fields from untrusted JSON without scattering bracketed mypy - suppression directives across the payload-coercion paths. - """ - if isinstance(value, (int, float)) and not isinstance(value, bool): - return float(value) - if isinstance(value, str): - try: - return float(value) - except ValueError: - return None - return None - - -def _coerce_int(value: object) -> int | None: - """Best-effort projection of a JSON value onto ``int`` (or ``None``).""" - if isinstance(value, bool): - # bool is a subclass of int but we don't want True / False - # silently becoming 1 / 0 in user-visible row counts. - return None - if isinstance(value, int): - return value - if isinstance(value, float): - return int(value) - if isinstance(value, str): - try: - return int(value) - except ValueError: - return None - return None - - -class BackfillKind(str, Enum): - """What kind of maintenance operation this is. - - The values map to the typed-operation taxonomy the rest of the - maintenance cluster expects (see #1144). - """ - - # Typed taxonomy (issue #1144). - ARCHIVE_SUBSET = "archive-subset" - DERIVED_REBUILD = "derived-rebuild" - INDEX_REPAIR = "index-repair" - SEMANTIC_REMATERIALIZE = "semantic-rematerialize" - CONFIG_DRIVEN = "config-driven" - - -_RETIRED_STORED_KIND_MAP: dict[str, BackfillKind] = { - "backfill": BackfillKind.DERIVED_REBUILD, - "rebuild": BackfillKind.DERIVED_REBUILD, - "reindex": BackfillKind.INDEX_REPAIR, - "reset": BackfillKind.CONFIG_DRIVEN, -} - - -def _coerce_backfill_kind(value: object) -> BackfillKind: - text = str(value) - try: - return BackfillKind(text) - except ValueError: - return _RETIRED_STORED_KIND_MAP.get(text, BackfillKind.DERIVED_REBUILD) - - -@dataclass(frozen=True) -class MaintenanceScope: - """Typed scope (target ids + typed filter) for a backfill. - - ``targets`` is the resolved canonical target-name tuple; ``filter`` - is a typed :class:`MaintenanceScopeFilter` carrying the named scope - dimensions agreed across CLI / daemon HTTP / MCP. An empty filter - means "full scope for the listed targets". - """ - - targets: tuple[str, ...] - filter: MaintenanceScopeFilter = field(default_factory=MaintenanceScopeFilter) - - def to_dict(self) -> JSONDocument: - return json_document( - { - "targets": list(self.targets), - "filter": self.filter.to_dict(), - } - ) - - @classmethod - def from_dict(cls, payload: dict[str, object]) -> MaintenanceScope: - targets_raw = payload.get("targets", ()) or () - if not isinstance(targets_raw, (list, tuple)): - raise TypeError(f"scope.targets must be a list/tuple, got {type(targets_raw).__name__}") - targets = tuple(str(t) for t in targets_raw) - filter_raw = payload.get("filter") - if filter_raw is None: - filter_obj = MaintenanceScopeFilter() - elif isinstance(filter_raw, MaintenanceScopeFilter): - filter_obj = filter_raw - elif isinstance(filter_raw, dict): - filter_obj = MaintenanceScopeFilter.from_dict(filter_raw) - else: - raise TypeError(f"scope.filter must be a dict, got {type(filter_raw).__name__}") - return cls(targets=targets, filter=filter_obj) - - -@dataclass(frozen=True) -class FailureSample: - """One bounded, structured failure sample. - - Executors must classify the failure (``kind``) and supply enough - locator to find the offending row (``locator``) and a short - human-readable ``message``. The full raw exception payload must not - leak through this surface. - """ - - kind: str - locator: str - message: str - - def to_dict(self) -> JSONDocument: - return json_document( - { - "kind": self.kind, - "locator": self.locator, - "message": self.message, - } - ) - - -@dataclass(frozen=True) -class BoundedFailureSamples: - """Bounded list of :class:`FailureSample` with a ``truncated`` flag. - - Use :meth:`from_samples` to construct: it clamps to - :data:`MAX_FAILURE_SAMPLES` and sets ``truncated`` accordingly. - """ - - samples: tuple[FailureSample, ...] = () - truncated: bool = False - - @classmethod - def from_samples(cls, samples: list[FailureSample] | tuple[FailureSample, ...]) -> BoundedFailureSamples: - seq = tuple(samples) - if len(seq) <= MAX_FAILURE_SAMPLES: - return cls(samples=seq, truncated=False) - return cls(samples=seq[:MAX_FAILURE_SAMPLES], truncated=True) - - def to_dict(self) -> JSONDocument: - return json_document( - { - "samples": [sample.to_dict() for sample in self.samples], - "truncated": self.truncated, - } - ) - - -@dataclass -class BackfillOperation: - """Typed model for a maintenance backfill operation. - - Carries identity, scope, reason, status, progress, resume cursor, - bounded failure samples, and metrics for one maintenance attempt. - The legacy ``targets`` tuple, ``affected_rows``, ``estimated_time_s``, - ``results``, and ``error`` fields are preserved so existing - surfaces continue to work; new code should prefer the typed - :attr:`scope`, :attr:`reason`, :attr:`resume_cursor`, - :attr:`failure_samples`, and :attr:`metrics` fields. - """ - - operation_id: str - kind: BackfillKind - targets: tuple[str, ...] - status: OperationStatus = OperationStatus.PENDING - progress: float = 0.0 - started_at: str | None = None - completed_at: str | None = None - error: str | None = None - affected_rows: int = 0 - estimated_time_s: float = 0.0 - results: list[JSONDocument] = field(default_factory=list) - - # Typed planner contract (issue #1144). - scope: MaintenanceScope | None = None - reason: InvalidationReason | None = None - resume_cursor: str | None = None - failure_samples: BoundedFailureSamples = field(default_factory=BoundedFailureSamples) - metrics: dict[str, float] = field(default_factory=dict) - - def __post_init__(self) -> None: - # Always keep ``scope.targets`` synchronized with ``targets`` so - # callers can rely on either field without divergence. If the - # constructor was called without a scope, derive one from the - # tuple; if a scope was supplied, trust it as authoritative. - if self.scope is None: - object.__setattr__(self, "scope", MaintenanceScope(targets=self.targets)) - elif self.scope.targets != self.targets: - object.__setattr__(self, "targets", self.scope.targets) - - def to_dict(self) -> JSONDocument: - scope = self.scope if self.scope is not None else MaintenanceScope(targets=self.targets) - return json_document( - { - "operation_id": self.operation_id, - "kind": self.kind.value, - "targets": list(self.targets), - "status": self.status.value, - "progress": self.progress, - "started_at": self.started_at, - "completed_at": self.completed_at, - "error": self.error, - "affected_rows": self.affected_rows, - "estimated_time_s": self.estimated_time_s, - "results": self.results, - "scope": scope.to_dict(), - "reason": (self.reason.value if self.reason is not None else None), - "resume_cursor": self.resume_cursor, - "failure_samples": self.failure_samples.to_dict(), - "metrics": dict(self.metrics), - } - ) - - @classmethod - def from_dict(cls, payload: dict[str, object]) -> BackfillOperation: - """Reconstruct a :class:`BackfillOperation` from its ``to_dict`` form. - - Used by :mod:`polylogue.maintenance.registry` to rehydrate - persisted operation snapshots from - ``/.maintenance-state/*.json``. Unknown enum values - round-trip through their literal string form so a snapshot - written by an older or newer build is still readable. - """ - - op_id = str(payload.get("operation_id", "")) - kind = _coerce_backfill_kind(payload.get("kind", BackfillKind.DERIVED_REBUILD.value)) - status_raw = str(payload.get("status", OperationStatus.PENDING.value)) - try: - status = OperationStatus(status_raw) - except ValueError: - status = OperationStatus.PENDING - targets_raw = payload.get("targets") or () - if not isinstance(targets_raw, (list, tuple)): - targets_raw = () - targets = tuple(str(t) for t in targets_raw) - results_raw = payload.get("results") or [] - results: list[JSONDocument] = [] - if isinstance(results_raw, (list, tuple)): - for entry in results_raw: - if isinstance(entry, dict): - results.append(json_document(entry)) - scope_raw = payload.get("scope") - scope_obj: MaintenanceScope | None - if isinstance(scope_raw, dict): - try: - scope_obj = MaintenanceScope.from_dict(scope_raw) - except (TypeError, ValueError): - scope_obj = None - else: - scope_obj = None - reason_raw = payload.get("reason") - reason_obj: InvalidationReason | None - if isinstance(reason_raw, str): - try: - reason_obj = InvalidationReason(reason_raw) - except ValueError: - reason_obj = None - else: - reason_obj = None - resume_cursor_raw = payload.get("resume_cursor") - resume_cursor = resume_cursor_raw if isinstance(resume_cursor_raw, str) else None - failure_raw = payload.get("failure_samples") - if isinstance(failure_raw, dict): - samples_raw = failure_raw.get("samples") or () - truncated = bool(failure_raw.get("truncated", False)) - samples: list[FailureSample] = [] - if isinstance(samples_raw, (list, tuple)): - for entry in samples_raw: - if isinstance(entry, dict): - samples.append( - FailureSample( - kind=str(entry.get("kind", "")), - locator=str(entry.get("locator", "")), - message=str(entry.get("message", "")), - ) - ) - failure_samples = BoundedFailureSamples(samples=tuple(samples), truncated=truncated) - else: - failure_samples = BoundedFailureSamples() - metrics_raw = payload.get("metrics") or {} - metrics: dict[str, float] = {} - if isinstance(metrics_raw, dict): - for key, value in metrics_raw.items(): - coerced = _coerce_float(value) - if coerced is not None: - metrics[str(key)] = coerced - progress = _coerce_float(payload.get("progress", 0.0)) or 0.0 - affected_rows = _coerce_int(payload.get("affected_rows", 0)) or 0 - estimated_time_s = _coerce_float(payload.get("estimated_time_s", 0.0)) or 0.0 - started_raw = payload.get("started_at") - completed_raw = payload.get("completed_at") - error_raw = payload.get("error") - return cls( - operation_id=op_id, - kind=kind, - targets=targets, - status=status, - progress=progress, - started_at=started_raw if isinstance(started_raw, str) else None, - completed_at=completed_raw if isinstance(completed_raw, str) else None, - error=error_raw if isinstance(error_raw, str) else None, - affected_rows=affected_rows, - estimated_time_s=estimated_time_s, - results=results, - scope=scope_obj, - reason=reason_obj, - resume_cursor=resume_cursor, - failure_samples=failure_samples, - metrics=metrics, - ) - - -def _collect_archive_debt_statuses( - config: Config, - *, - include_expensive: bool, - target_names: tuple[str, ...] = (), -) -> dict[str, ArchiveDebtStatus]: - """Collect archive-debt statuses over ``index.db``. - - Returns an empty mapping when ``index.db`` does not yet exist (fresh - archive before first ingest), mirroring the staleness-inventory contract. - """ - from contextlib import closing - - from polylogue.storage.archive_identity import archive_file_set_root - from polylogue.storage.repair import collect_archive_debt_statuses_sync - from polylogue.storage.sqlite.connection_profile import open_readonly_connection - - # polylogue-yla8.1 split-root contract: config.db_path always names a - # concrete index.db (explicit override or resolved active generation). - index_db = config.db_path - if not index_db.exists(): - return {} - configured_root = archive_file_set_root(archive_root=config.archive_root, db_path=index_db) - with closing(open_readonly_connection(index_db)) as conn: - return collect_archive_debt_statuses_sync( - conn, - db_path=index_db, - include_expensive=include_expensive, - probe_only=False, - target_names=target_names, - configured_root=configured_root, - ) - - -def _scoped_preview_counts( - config: Config, - *, - targets: tuple[str, ...], - session_ids: tuple[str, ...], -) -> dict[str, int]: - """Count debt over exactly the requested sessions, per target. - - Only targets whose execution route filters by ``session_ids`` and that - have a scoped counter appear in the result; the caller falls back to the - request-size clamp for the rest. - """ - from contextlib import closing - - from polylogue.storage.repair import count_empty_sessions_sync - from polylogue.storage.sqlite.connection_profile import open_readonly_connection - - if "empty_sessions" not in targets: - return {} - index_db = config.db_path - if not index_db.exists(): - return {} - with closing(open_readonly_connection(index_db)) as conn: - return {"empty_sessions": count_empty_sessions_sync(conn, session_ids=session_ids)} - - -def preview_backfill( - config: Config, - targets: tuple[str, ...], - *, - scope_filter: MaintenanceScopeFilter | None = None, -) -> BackfillOperation: - """Preview what would be rebuilt for the given targets. Read-only. - - Uses existing DerivedModelStatus queries and archive debt collection - to report how many rows are affected and an estimated completion time. - No mutations are performed. - """ - from polylogue.storage.repair import ( - preview_counts_from_archive_debt, - ) - - operation_id = str(uuid.uuid4()) - catalog = build_maintenance_target_catalog() - resolved = catalog.resolve_or_default(targets) - resolved_names = tuple(spec.name for spec in resolved) - effective_filter = scope_filter or MaintenanceScopeFilter() - - if not resolved_names: - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=(), - status=OperationStatus.FAILED, - error="No valid targets resolved from input", - scope=MaintenanceScope(targets=(), filter=effective_filter), - ) - - # Thread the caller's archive db_path through the planner instead of - # relying on ambient defaults. The original ``connection_context(None)`` - # call ignored ``config.db_path`` entirely, which made the planner - # behave inconsistently in tests and multi-archive runtimes. - include_expensive = any(spec.archive_readiness_requires_deep for spec in resolved) - debt_statuses = _collect_archive_debt_statuses( - config, - include_expensive=include_expensive, - target_names=resolved_names, - ) - - preview = preview_counts_from_archive_debt(debt_statuses) - - scope_refusals: list[FailureSample] = [] - unsupported_targets: set[str] = set() - for target_name in resolved_names: - unsupported = unsupported_scope_dimensions(effective_filter, target=target_name) - if unsupported: - unsupported_targets.add(target_name) - scope_refusals.append( - FailureSample( - kind="UnsupportedScopeDimension", - locator=f"target:{target_name}", - message=f"Unsupported scope dimensions for target {target_name!r}: {', '.join(unsupported)}", - ) - ) - - # Compute affected rows and estimated time only for targets that can - # apply the requested scope. A rejected target has no executable plan. - total_rows = 0 - for name in resolved_names: - if name not in unsupported_targets: - total_rows += preview.get(name, 0) - - # Rough estimate: ~50 rows/s for complex rebuilds (session insights, - # actions), ~500 rows/s for simple repairs (FTS, WAL). - estimated_time_s = total_rows / 50.0 if total_rows > 0 else 0.0 - - # Build per-target preview results from debt statuses - preview_results: list[JSONDocument] = [] - reason: InvalidationReason | None = None - for name in resolved_names: - if name in unsupported_targets: - continue - status = debt_statuses.get(name) - if status is not None: - preview_results.append(status.to_dict()) - if reason is None: - reason = _derive_invalidation_reason(status) - - # When the caller narrows by session_ids, the affected-rows estimate must - # be counted over the requested sessions, not clamped by the size of the - # request: a filter naming healthy sessions plus unrelated archive debris - # would otherwise advertise rows the execution route deletes nowhere. - if effective_filter.session_ids is not None: - scoped_counts = _scoped_preview_counts( - config, - targets=tuple(name for name in resolved_names if name not in unsupported_targets), - session_ids=effective_filter.session_ids, - ) - scope_size = len(effective_filter.session_ids) - total_rows = 0 - for name in resolved_names: - if name in unsupported_targets: - continue - if name in scoped_counts: - total_rows += scoped_counts[name] - else: - # No scoped counter for this target yet: fall back to the - # request-size clamp so the preview still cannot advertise - # archive-wide debt as a narrowed plan. - total_rows += min(preview.get(name, 0), scope_size) - estimated_time_s = total_rows / 50.0 if total_rows > 0 else 0.0 - - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.FAILED if scope_refusals else OperationStatus.PENDING, - error="; ".join(sample.message for sample in scope_refusals) or None, - affected_rows=total_rows, - estimated_time_s=estimated_time_s, - results=preview_results, - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=reason, - failure_samples=BoundedFailureSamples.from_samples(scope_refusals), - ) - - -def execute_backfill( - config: Config, - targets: tuple[str, ...], - *, - dry_run: bool = False, - scope_filter: MaintenanceScopeFilter | None = None, -) -> BackfillOperation: - """Execute (or dry-run) a backfill for the given targets. - - Dispatches to existing repair.py infrastructure via - run_selected_maintenance. Progress is reported via structured logging. - """ - from polylogue.storage.repair import ( - preview_counts_from_archive_debt, - run_selected_maintenance, - ) - - operation_id = str(uuid.uuid4()) - catalog = build_maintenance_target_catalog() - resolved = catalog.resolve_or_default(targets) - resolved_names = tuple(spec.name for spec in resolved) - effective_filter = scope_filter or MaintenanceScopeFilter() - - if not resolved_names: - return BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=(), - status=OperationStatus.FAILED, - error="No valid targets resolved from input", - scope=MaintenanceScope(targets=(), filter=effective_filter), - ) - - logger.info( - "backfill_starting", - operation_id=operation_id, - targets=resolved_names, - dry_run=dry_run, - ) - - # Thread the caller's archive db_path through the planner instead of - # relying on ambient defaults. See ``preview_backfill`` above. - debt_statuses = _collect_archive_debt_statuses( - config, - include_expensive=False, - target_names=resolved_names, - ) - preview = preview_counts_from_archive_debt(debt_statuses) - - reason: InvalidationReason | None = None - for name in resolved_names: - status = debt_statuses.get(name) - if status is not None: - reason = _derive_invalidation_reason(status) - if reason is not None: - break - - started_at = datetime.now(timezone.utc).isoformat() - from polylogue.maintenance.registry import persist_operation_snapshot - - persist_operation_snapshot( - config, - BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.RUNNING, - progress=0.0, - started_at=started_at, - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=reason, - ), - dry_run=dry_run, - ) - - try: - repair_targets = tuple(n for n in resolved_names if n in SAFE_REPAIR_TARGETS) - cleanup_targets = tuple(n for n in resolved_names if n in CLEANUP_TARGETS) - - repair_results = run_selected_maintenance( - config, - repair=bool(repair_targets), - cleanup=bool(cleanup_targets), - dry_run=dry_run, - preview_counts=preview, - targets=resolved_names, - scope_filter=effective_filter, - ) - - completed_at = datetime.now(timezone.utc).isoformat() - all_success = all(r.success for r in repair_results) - total_repaired = sum(r.repaired_count for r in repair_results) - scope_refusals: list[FailureSample] = [] - for target_name in resolved_names: - unsupported = unsupported_scope_dimensions(effective_filter, target=target_name) - if unsupported: - scope_refusals.append( - FailureSample( - kind="UnsupportedScopeDimension", - locator=f"target:{target_name}", - message=f"Unsupported scope dimensions for target {target_name!r}: {', '.join(unsupported)}", - ) - ) - - logger.info( - "backfill_completed", - operation_id=operation_id, - targets=resolved_names, - dry_run=dry_run, - repaired_count=total_repaired, - success=all_success, - ) - - result = BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.COMPLETED if all_success else OperationStatus.FAILED, - progress=1.0, - started_at=started_at, - completed_at=completed_at, - affected_rows=total_repaired, - estimated_time_s=0.0, - results=[r.to_dict() for r in repair_results], - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=reason, - failure_samples=BoundedFailureSamples.from_samples(scope_refusals), - metrics={"repaired_count": float(total_repaired)}, - ) - persist_operation_snapshot(config, result, dry_run=dry_run) - return result - except Exception as exc: - logger.exception( - "backfill_failed", - operation_id=operation_id, - targets=resolved_names, - error=str(exc), - ) - result = BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=resolved_names, - status=OperationStatus.FAILED, - progress=0.0, - started_at=started_at, - error=f"Backfill failed: {exc}", - scope=MaintenanceScope(targets=resolved_names, filter=effective_filter), - reason=reason, - failure_samples=BoundedFailureSamples.from_samples( - [ - FailureSample( - kind=type(exc).__name__, - locator="planner.execute_backfill", - message=str(exc), - ) - ] - ), - ) - persist_operation_snapshot(config, result, dry_run=dry_run) - return result - - -def _derive_invalidation_reason(status: object) -> InvalidationReason | None: - """Translate a :class:`DerivedModelStatus` into an :class:`InvalidationReason`. - - Kept as a module-level helper (rather than a method on - :class:`DerivedModelStatus`) so the storage-level status type does - not depend on the planner enum; the planner is the surface that - classifies staleness. - """ - from polylogue.maintenance.models import DerivedModelStatus - - if not isinstance(status, DerivedModelStatus): - return None - if status.invalidated_reason is not None: - return status.invalidated_reason - if status.ready: - return None - if status.materialized_documents == 0 and status.source_documents > 0: - return InvalidationReason.MISSING - if status.matches_version is False: - return InvalidationReason.STALE_MATERIALIZER_VERSION - if status.stale_rows > 0: - return InvalidationReason.SOURCE_CHANGED - if status.missing_provenance_rows > 0: - return InvalidationReason.PARSER_OR_SCHEMA_CHANGED - return InvalidationReason.UNKNOWN - - -__all__ = [ - "MAX_FAILURE_SAMPLES", - "BackfillKind", - "BackfillOperation", - "BoundedFailureSamples", - "FailureSample", - "InvalidationReason", - "MaintenanceScope", - "MaintenanceScopeFilter", - "execute_backfill", - "preview_backfill", -] diff --git a/polylogue/maintenance/preview.py b/polylogue/maintenance/preview.py deleted file mode 100644 index c49701ea8a..0000000000 --- a/polylogue/maintenance/preview.py +++ /dev/null @@ -1,441 +0,0 @@ -"""Preview surface: staleness inventory by model and scope. - -Read-only enumeration of stale or missing derived artifacts in the archive, -grouped by model and typed ``InvalidationReason``. Used by the maintenance -planner (``polylogue ops maintenance preview``) and by operators who want to see -"what will be rebuilt and why" before triggering any mutation. - -The inventory is sourced from -:func:`polylogue.storage.derived.derived_status.collect_derived_model_statuses_sync` -plus the archive-debt orphan counts in -:mod:`polylogue.storage.repair`; the preview never mutates the database (a -write-watching SQLite hook used in tests confirms zero writes during a -preview). - -Models inventoried: - -* ``messages_fts``, - ``session_profile_rows``, ``session_work_events``, - ``session_work_events_fts``, phase interval rows, - ``threads``, ``session_tag_rollups`` — derived read - models reported by ``collect_derived_model_statuses_sync``. -* ``transcript_embeddings``, ``retrieval_evidence``, - ``retrieval_inference``, ``retrieval_enrichment`` — retrieval-layer - read models. -* ``empty_sessions`` — archive-cleanup scope for positively classified debris. - -The inventory is produced at the model granularity reported by the -derived-status collector; mapping back to ``MaintenanceTargetSpec`` is the -planner's job and lives in ``planner.preview_backfill``. -""" - -from __future__ import annotations - -import sqlite3 -from collections.abc import Iterable -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum -from pathlib import Path - -from polylogue.core.json import JSONDocument, json_document -from polylogue.maintenance.models import DerivedModelStatus -from polylogue.storage.repair import count_empty_sessions_sync - - -class InvalidationReason(str, Enum): - """Typed reason a derived row is considered stale or missing. - - The reasons are not mutually exclusive — a single model may emit - multiple :class:`StalenessItem` rows with different reasons. - """ - - MISSING = "missing" - """Source rows exist but no derived row has been materialized yet.""" - - STALE = "stale" - """Derived row exists but is older than its source (mtime/content drift).""" - - ORPHAN = "orphan" - """Derived row references a parent row that no longer exists.""" - - MISSING_PROVENANCE = "missing_provenance" - """Derived row exists but cannot be linked back to an upstream input.""" - - VERSION_MISMATCH = "version_mismatch" - """Derived row was produced by an older ``materializer_version``.""" - - ORPHAN_ARCHIVE_ROW = "orphan_archive_row" - """Archive row (messages, attachments, content blocks) with no parent.""" - - -_DERIVED_MODEL_SCOPE = "derived" -_RETRIEVAL_MODEL_SCOPE = "retrieval" -_ARCHIVE_CLEANUP_SCOPE = "archive_cleanup" -ALL_SCOPES: tuple[str, ...] = ( - _DERIVED_MODEL_SCOPE, - _RETRIEVAL_MODEL_SCOPE, - _ARCHIVE_CLEANUP_SCOPE, -) - - -@dataclass(frozen=True, slots=True) -class StalenessItem: - """One (model, reason) staleness count, optionally with sample ids.""" - - model: str - scope: str - reason: InvalidationReason - count: int - source_total: int - materialized_total: int - detail: str - sample_ids: tuple[str, ...] = () - truncated: bool = False - - @property - def fraction(self) -> float: - """Fraction of source rows considered stale for this reason. - - Returns 0.0 if ``source_total`` is zero. Capped at 1.0. - """ - - if self.source_total <= 0: - return 0.0 - ratio = self.count / self.source_total - return ratio if ratio < 1.0 else 1.0 - - def to_dict(self) -> JSONDocument: - return json_document( - { - "model": self.model, - "scope": self.scope, - "reason": self.reason.value, - "count": self.count, - "source_total": self.source_total, - "materialized_total": self.materialized_total, - "fraction": round(self.fraction, 6), - "detail": self.detail, - "sample_ids": list(self.sample_ids), - "truncated": self.truncated, - } - ) - - -@dataclass(frozen=True, slots=True) -class StalenessInventory: - """Complete staleness inventory for one preview call.""" - - captured_at: str - db_path: str - scopes: tuple[str, ...] - items: tuple[StalenessItem, ...] = field(default_factory=tuple) - - def by_model(self) -> dict[str, tuple[StalenessItem, ...]]: - result: dict[str, list[StalenessItem]] = {} - for item in self.items: - result.setdefault(item.model, []).append(item) - return {model: tuple(rows) for model, rows in result.items()} - - def total_stale(self) -> int: - return sum(item.count for item in self.items) - - def to_dict(self) -> JSONDocument: - return json_document( - { - "captured_at": self.captured_at, - "db_path": self.db_path, - "scopes": list(self.scopes), - "total_stale": self.total_stale(), - "items": [item.to_dict() for item in self.items], - } - ) - - -# --------------------------------------------------------------------------- -# Inventory builders -# --------------------------------------------------------------------------- - - -def _model_items( - status: DerivedModelStatus, - *, - scope: str, -) -> list[StalenessItem]: - """Project a :class:`DerivedModelStatus` into reason-tagged rows. - - Always emits at least one row per (model, possible reason) so empty - models report ``count=0`` explicitly rather than absence. - """ - - source_total = status.source_documents or status.source_rows or status.materialized_documents - materialized_total = status.materialized_rows or status.materialized_documents - - items: list[StalenessItem] = [] - - pending = max(0, int(status.pending_rows or 0) + int(status.pending_documents or 0)) - items.append( - StalenessItem( - model=status.name, - scope=scope, - reason=InvalidationReason.MISSING, - count=pending, - source_total=source_total, - materialized_total=materialized_total, - detail=(f"{pending:,} unmaterialized rows/documents" if pending > 0 else "All source rows materialized"), - ) - ) - - stale = max(0, int(status.stale_rows or 0)) - items.append( - StalenessItem( - model=status.name, - scope=scope, - reason=InvalidationReason.STALE, - count=stale, - source_total=source_total, - materialized_total=materialized_total, - detail=(f"{stale:,} stale rows" if stale > 0 else "No stale rows"), - ) - ) - - orphan = max(0, int(status.orphan_rows or 0)) - items.append( - StalenessItem( - model=status.name, - scope=scope, - reason=InvalidationReason.ORPHAN, - count=orphan, - source_total=source_total, - materialized_total=materialized_total, - detail=(f"{orphan:,} orphan rows" if orphan > 0 else "No orphan rows"), - ) - ) - - missing_provenance = max(0, int(status.missing_provenance_rows or 0)) - if missing_provenance > 0: - items.append( - StalenessItem( - model=status.name, - scope=scope, - reason=InvalidationReason.MISSING_PROVENANCE, - count=missing_provenance, - source_total=source_total, - materialized_total=materialized_total, - detail=f"{missing_provenance:,} rows missing provenance", - ) - ) - - if status.matches_version is False and materialized_total > 0: - items.append( - StalenessItem( - model=status.name, - scope=scope, - reason=InvalidationReason.VERSION_MISMATCH, - count=materialized_total, - source_total=source_total, - materialized_total=materialized_total, - detail=( - f"materializer_version={status.materializer_version!r}; " - f"all {materialized_total:,} rows would be rebuilt" - ), - ) - ) - - return items - - -_DERIVED_MODEL_NAMES: frozenset[str] = frozenset( - { - "messages_fts", - "session_profile_rows", - "session_work_events", - "session_work_events_fts", - "session_phases", - "threads", - "session_tag_rollups", - } -) - -_RETRIEVAL_MODEL_NAMES: frozenset[str] = frozenset( - { - "transcript_embeddings", - "retrieval_evidence", - "retrieval_inference", - "retrieval_enrichment", - } -) - - -def _archive_cleanup_items( - conn: sqlite3.Connection, - *, - db_path: Path | str | None, - include_expensive: bool, -) -> list[StalenessItem]: - """Orphan-row counts for archive-cleanup scopes (read-only).""" - - if not include_expensive: - return [ - StalenessItem( - model=model, - scope=_ARCHIVE_CLEANUP_SCOPE, - reason=InvalidationReason.ORPHAN_ARCHIVE_ROW, - count=0, - source_total=0, - materialized_total=0, - detail="Exact archive-cleanup count skipped by shallow preview", - truncated=True, - ) - for model in ("empty_sessions",) - ] - - empty_sessions = count_empty_sessions_sync(conn) - - rows: list[tuple[str, int, str]] = [ - ( - "empty_sessions", - empty_sessions, - "sessions with no messages", - ), - ] - - items: list[StalenessItem] = [] - for model, count, label in rows: - items.append( - StalenessItem( - model=model, - scope=_ARCHIVE_CLEANUP_SCOPE, - reason=InvalidationReason.ORPHAN_ARCHIVE_ROW, - count=count, - source_total=count, - materialized_total=count, - detail=(f"{count:,} {label}" if count > 0 else f"No {label}"), - ) - ) - return items - - -def _coerce_scopes(scopes: Iterable[str] | None) -> tuple[str, ...]: - if not scopes: - return ALL_SCOPES - requested = tuple(dict.fromkeys(scopes)) - unknown = [s for s in requested if s not in ALL_SCOPES] - if unknown: - raise ValueError(f"Unknown preview scopes: {unknown}; valid: {list(ALL_SCOPES)}") - return requested - - -def staleness_inventory( - db_path: Path | str | sqlite3.Connection | None = None, - *, - scopes: Iterable[str] | None = None, - verify_full: bool = True, - sample_limit: int = 0, -) -> StalenessInventory: - """Enumerate stale/missing derived artifacts by model and scope. - - Read-only — performs no mutations. Returns one row per - (model, :class:`InvalidationReason`) pair. Models with nothing stale - still produce explicit ``count=0`` rows for ``MISSING``, ``STALE``, - and ``ORPHAN`` so consumers can render a complete inventory without - distinguishing "absent" from "zero". - - Parameters - ---------- - db_path: - Path, open connection, or ``None`` to use the configured archive. - scopes: - Subset of :data:`ALL_SCOPES` to inventory. Defaults to all. - verify_full: - Whether to force full freshness verification in the derived-status - collector. Defaults to ``True`` for accurate counts. - sample_limit: - Reserved for future per-reason id sampling. Currently unused; the - inventory does not surface ids in this PR. (Future: surface up to - N ids per stale (model, reason) when the planner needs them.) - """ - - from contextlib import closing, nullcontext - - from polylogue.paths import archive_root - from polylogue.storage.derived.derived_status import collect_derived_model_statuses_sync - from polylogue.storage.sqlite.connection_profile import open_readonly_connection - - selected_scopes = _coerce_scopes(scopes) - _ = sample_limit # reserved; see docstring. - - captured_at = datetime.now(timezone.utc).isoformat() - - # The archive is the split-file store; reads target - # ``index.db`` (sessions/messages/blocks tree) directly. A pre-opened - # connection is used as-is; otherwise the caller's path — or the active - # ``index.db`` — is opened read-only. - from contextlib import AbstractContextManager - - connection_manager: AbstractContextManager[sqlite3.Connection] - if isinstance(db_path, sqlite3.Connection): - connection_manager = nullcontext(db_path) - else: - resolved = Path(db_path) if db_path is not None else archive_root() / "index.db" - if not resolved.exists(): - # No archive `index.db` yet (fresh archive before first ingest). - # There is nothing to inventory; report an empty result rather - # than failing to open a non-existent file. - return StalenessInventory( - captured_at=captured_at, - db_path=str(resolved), - scopes=selected_scopes, - items=(), - ) - connection_manager = closing(open_readonly_connection(resolved)) - - with connection_manager as conn: - if _DERIVED_MODEL_SCOPE in selected_scopes or _RETRIEVAL_MODEL_SCOPE in selected_scopes: - statuses = collect_derived_model_statuses_sync(conn, verify_full=verify_full) - else: - statuses = {} - resolved_path = _resolve_db_path(conn) - - items: list[StalenessItem] = [] - - if _DERIVED_MODEL_SCOPE in selected_scopes: - for name, status in statuses.items(): - if name in _DERIVED_MODEL_NAMES: - items.extend(_model_items(status, scope=_DERIVED_MODEL_SCOPE)) - - if _RETRIEVAL_MODEL_SCOPE in selected_scopes: - for name, status in statuses.items(): - if name in _RETRIEVAL_MODEL_NAMES: - items.extend(_model_items(status, scope=_RETRIEVAL_MODEL_SCOPE)) - - if _ARCHIVE_CLEANUP_SCOPE in selected_scopes: - items.extend(_archive_cleanup_items(conn, db_path=resolved_path, include_expensive=verify_full)) - - return StalenessInventory( - captured_at=captured_at, - db_path=resolved_path, - scopes=selected_scopes, - items=tuple(items), - ) - - -def _resolve_db_path(conn: sqlite3.Connection) -> str: - try: - row = conn.execute("PRAGMA database_list").fetchone() - except sqlite3.Error: - return ":memory:" - if row is None: - return ":memory:" - # PRAGMA database_list rows: (seq, name, file) - file_part = row[2] if len(row) > 2 else "" - return str(file_part) if file_part else ":memory:" - - -__all__ = [ - "ALL_SCOPES", - "InvalidationReason", - "StalenessInventory", - "StalenessItem", - "staleness_inventory", -] diff --git a/polylogue/maintenance/raw_authority.py b/polylogue/maintenance/raw_authority.py index cae001979a..6a6b86fc9c 100644 --- a/polylogue/maintenance/raw_authority.py +++ b/polylogue/maintenance/raw_authority.py @@ -18,8 +18,8 @@ if TYPE_CHECKING: from polylogue.sources.revision_backfill import RawParsePrefetchCache + from polylogue.storage.raw_convergence import RawConvergenceResult from polylogue.storage.raw_reconciler import RawAuthorityFrontierApplyReport, RawAuthorityFrontierCensus - from polylogue.storage.repair import RepairResult RAW_MATERIALIZATION_ORDINARY_BLOB_LIMIT_BYTES: Final = 64 * 1024 * 1024 @@ -37,7 +37,7 @@ class RawMaterializationCounts: ``candidate_count`` and ``pending_blob_bytes`` describe the *whole* unbounded backlog the pass measured (not the bounded per-pass batch): - ``repair_materialization`` enumerates every matching raw before + ``converge_materialization`` enumerates every matching raw before applying ``raw_artifact_limit``, so these two fields are how a caller detects a bulk-scale backlog the trickle conveyor is not designed for (polylogue-m6tp) without re-querying storage itself. @@ -114,7 +114,7 @@ def auto_resolve_stale_plan_blockers(config: Config) -> int: See ``storage.raw_authority.auto_resolve_stale_plan_blockers`` for why this is safe to run unattended: a stale-plan blocker requires no judgment content, and this is the one non-frontier blocker kind - ``repair_materialization`` checks archive-wide before doing any repair + ``converge_materialization`` checks archive-wide before doing any work at all (``unresolved_raw_replay_blockers``). """ from polylogue.storage.raw_authority import auto_resolve_stale_plan_blockers as _auto_resolve @@ -176,18 +176,18 @@ def archive_writer_rebuild_exclusion(archive_root: Path) -> Iterator[ArchiveWrit exclusion.release_if_safe() -def materialization_lease_refusal_result(error: BaseException) -> RepairResult | None: - """Translate only a rebuild-lease refusal into raw repair's typed result.""" +def materialization_lease_refusal_result(error: BaseException) -> RawConvergenceResult | None: + """Translate only a rebuild-lease refusal into the typed pass result.""" from polylogue.storage.index_generation import RebuildLeaseUnavailableError if not isinstance(error, RebuildLeaseUnavailableError): return None - from polylogue.storage.repair import raw_materialization_lease_refusal_result + from polylogue.storage.raw_convergence import raw_materialization_lease_refusal_result return raw_materialization_lease_refusal_result(error) -def repair_materialization( +def converge_materialization( config: Config, *, dry_run: bool, @@ -222,12 +222,12 @@ def repair_materialization( process-wide writer lock (the daemon's trickle conveyor) has a declared, enforced ceiling on how long it can hold that lock in one call, independent of ``raw_artifact_limit`` -- see - ``polylogue.storage.repair.repair_raw_materialization`` for why a fixed + ``polylogue.storage.raw_convergence.converge_raw_materialization`` for why a fixed component count alone did not bound hold time in practice. """ - from polylogue.storage.repair import repair_raw_materialization + from polylogue.storage.raw_convergence import converge_raw_materialization - return repair_raw_materialization( + return converge_raw_materialization( config, dry_run=dry_run, raw_artifact_limit=raw_artifact_limit, @@ -248,10 +248,10 @@ def whale_pass_candidate( """Read-only: pick one resource-blocked, stream-safe component to escalate. polylogue-t93b. See - ``polylogue.storage.repair.raw_materialization_whale_pass_candidate`` for + ``polylogue.storage.raw_convergence.raw_materialization_whale_pass_candidate`` for the selection contract; safe to call without the writer hold. """ - from polylogue.storage.repair import raw_materialization_whale_pass_candidate + from polylogue.storage.raw_convergence import raw_materialization_whale_pass_candidate return raw_materialization_whale_pass_candidate( config, @@ -297,5 +297,5 @@ def list_blockers(archive_root: Path, *, limit: int = 100, offset: int = 0) -> J "read_census", "read_detail", "recover_interrupted_frontier", - "repair_materialization", + "converge_materialization", ] diff --git a/polylogue/maintenance/registry.py b/polylogue/maintenance/registry.py deleted file mode 100644 index f0e6cda35d..0000000000 --- a/polylogue/maintenance/registry.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Persistent maintenance operation registry (issue #1197). - -The :mod:`polylogue.maintenance.replay` executor checkpoints in-flight -backfill operations to JSON state files under -``/.maintenance-state/.json``. Before #1197 those -files were write-only — there was no surface that could list them, tail -one specific operation, or prune the completed ones. The replay state -payload also only carried ``{cursor, results, repaired_count, -failure_count}``, which was not enough to reconstruct the full -:class:`~polylogue.maintenance.planner.BackfillOperation` snapshot the -shared envelope expects. - -This module is the durable read surface for that state directory: - -* :meth:`MaintenanceOperationRegistry.list_operations` returns one - :class:`OperationRecord` per persisted state file, sorted newest-first - by ``updated_at``; -* :meth:`MaintenanceOperationRegistry.get_operation` returns one record - by id (or ``None`` when the file does not exist); -* :meth:`MaintenanceOperationRegistry.prune_completed` removes - state files for operations whose ``status == "completed"`` and whose - ``updated_at`` is older than the configured TTL. Failed operations - stay on disk until manually cleared (operators want them for - diagnosis). - -The state file payload is upgraded to carry the full -``BackfillOperation.to_dict()`` under a ``"operation"`` key. The legacy -top-level fields (``cursor``, ``targets``, ``repaired_count``, -``failure_count``) are still written so old readers and the existing -resume code path keep working unchanged. -""" - -from __future__ import annotations - -import json -import os -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Final - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.core.json import JSONDocument, json_document, loads -from polylogue.logging import get_logger -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.maintenance.planner import BackfillOperation - -logger = get_logger(__name__) - -#: Default TTL for completed-successful operations: 7 days. -DEFAULT_COMPLETED_TTL: Final[timedelta] = timedelta(days=7) - -#: Subdirectory under :attr:`Config.archive_root` used for replay state -#: files. Mirrors :data:`polylogue.maintenance.replay._STATE_DIRNAME`; -#: kept private there so the path stays an implementation detail of the -#: replay module, and duplicated here so the registry does not need to -#: import the replay module (avoids an import cycle). -_STATE_DIRNAME: Final[str] = ".maintenance-state" - - -def _state_dir(config: Config) -> Path: - """Return the on-disk state directory under ``archive_root``.""" - return Path(config.archive_root) / _STATE_DIRNAME - - -def _fsync_directory(path: Path) -> None: - """Flush a directory after publishing a replacement entry.""" - directory = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) - try: - os.fsync(directory) - finally: - os.close(directory) - - -def persist_operation_snapshot(config: Config, operation: BackfillOperation, *, dry_run: bool) -> Path: - """Persist a synchronous operation so its returned id is immediately inspectable.""" - directory = _state_dir(config) - directory.mkdir(parents=True, exist_ok=True) - path = directory / f"{validate_operation_id(operation.operation_id)}.json" - temporary = path.with_suffix(".json.tmp") - payload = json_document( - { - "operation_id": operation.operation_id, - "targets": list(operation.targets), - "started_at": operation.started_at, - "updated_at": datetime.now(timezone.utc).isoformat(), - "completed_at": operation.completed_at, - "dry_run": dry_run, - "results": list(operation.results), - "operation": operation.to_dict(), - } - ) - temporary.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") - os.replace(temporary, path) - _fsync_directory(path.parent) - return path - - -@dataclass(frozen=True) -class OperationRecord: - """One persisted operation snapshot, projected for the read surface. - - Carries the fully reconstructed :class:`BackfillOperation` along - with the file-level timestamps the operator actually wants when - listing or pruning: ``updated_at`` (when the executor last - checkpointed) and ``state_path`` (so a user can locate the raw - file). - """ - - operation: BackfillOperation - updated_at: str - state_path: Path - - @property - def operation_id(self) -> str: - return self.operation.operation_id - - @property - def status(self) -> OperationStatus: - return self.operation.status - - def to_dict(self) -> JSONDocument: - return json_document( - { - "operation": self.operation.to_dict(), - "updated_at": self.updated_at, - "state_path": str(self.state_path), - } - ) - - -def _parse_updated_at(value: object) -> datetime | None: - """Parse the ``updated_at`` ISO timestamp recorded in the state file.""" - if not isinstance(value, str) or not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - -@dataclass(frozen=True) -class RegistryReadIssue: - """A state-file read failure that must not be presented as absence.""" - - path: Path - code: str - detail: str - - -def _load_record(path: Path, issues: list[RegistryReadIssue] | None = None) -> OperationRecord | None: - """Load one state file and project it into an :class:`OperationRecord`. - - Returns ``None`` for unparseable files. State directories are - operator-visible so anything is possible there — partial writes - after a SIGKILL, stray files, hand-edited JSON. Any failure is - logged at warning level and skipped, never raised, so a single bad - file never poisons the whole listing. - """ - try: - raw_text = path.read_text() - except OSError as exc: - logger.warning("maintenance_registry_read_failed", path=str(path), error=str(exc)) - if issues is not None: - issues.append(RegistryReadIssue(path, "read_failed", str(exc))) - return None - try: - raw = loads(raw_text) - except ValueError as exc: - logger.warning("maintenance_registry_parse_failed", path=str(path), error=str(exc)) - if issues is not None: - issues.append(RegistryReadIssue(path, "invalid_json", str(exc))) - return None - if not isinstance(raw, dict): - logger.warning("maintenance_registry_payload_not_object", path=str(path)) - if issues is not None: - issues.append(RegistryReadIssue(path, "invalid_payload", "state file must contain an object")) - return None - # ``loads`` returns the recursive ``JSONValue`` alias. Project it - # onto ``dict[str, object]`` for the rehydration helpers — the - # nested validators (``BackfillOperation.from_dict``, - # ``_legacy_record_to_operation``) treat untrusted values - # defensively, so the cast cannot widen the trust boundary. - payload: dict[str, object] = {str(k): v for k, v in raw.items()} - op_payload = payload.get("operation") - if isinstance(op_payload, dict): - operation = BackfillOperation.from_dict({str(k): v for k, v in op_payload.items()}) - else: - # Legacy state file shape (pre-#1197): synthesize a minimal - # BackfillOperation from the top-level fields so the registry - # still surfaces in-flight pre-upgrade operations. - operation = _legacy_record_to_operation(payload, path) - updated_at_raw = payload.get("updated_at") or payload.get("started_at") or "" - updated_at = updated_at_raw if isinstance(updated_at_raw, str) else "" - return OperationRecord(operation=operation, updated_at=updated_at, state_path=path) - - -def _legacy_record_to_operation(raw: dict[str, object], path: Path) -> BackfillOperation: - """Best-effort projection of a pre-#1197 state payload onto :class:`BackfillOperation`.""" - from polylogue.maintenance.planner import BackfillKind, MaintenanceScope - - targets_raw = raw.get("targets") or () - if not isinstance(targets_raw, (list, tuple)): - targets_raw = () - targets = tuple(str(t) for t in targets_raw) - op_id_raw = raw.get("operation_id") - op_id = op_id_raw if isinstance(op_id_raw, str) else path.stem - cursor_raw = raw.get("cursor") - cursor = cursor_raw if isinstance(cursor_raw, str) else None - started_raw = raw.get("started_at") - started = started_raw if isinstance(started_raw, str) else None - return BackfillOperation( - operation_id=op_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=targets, - status=OperationStatus.RUNNING, - started_at=started, - resume_cursor=cursor, - scope=MaintenanceScope(targets=targets), - ) - - -@dataclass(frozen=True) -class MaintenanceOperationRegistry: - """Read-side registry over the on-disk replay state directory. - - The registry is intentionally stateless and re-reads the directory - on every call. There is no in-process cache — concurrent - daemon/CLI/MCP readers must agree with the filesystem, not with one - another, and the file count is bounded by the operator's - in-flight + recent operations (typically << 100), so a fresh - listdir per call is cheap. - """ - - config: Config - - @property - def state_dir(self) -> Path: - return _state_dir(self.config) - - def _state_file_paths(self) -> list[Path]: - directory = self.state_dir - if not directory.exists(): - return [] - out: list[Path] = [] - try: - for entry in directory.iterdir(): - if entry.is_file() and entry.suffix == ".json": - out.append(entry) - except OSError as exc: - logger.warning( - "maintenance_registry_iterdir_failed", - directory=str(directory), - error=str(exc), - ) - return [] - return out - - def list_operations(self) -> tuple[OperationRecord, ...]: - """Return every persisted operation snapshot, newest first.""" - records: list[OperationRecord] = [] - for path in self._state_file_paths(): - record = _load_record(path) - if record is not None: - records.append(record) - records.sort(key=lambda r: r.updated_at, reverse=True) - return tuple(records) - - def list_operations_diagnostic(self) -> tuple[tuple[OperationRecord, ...], tuple[RegistryReadIssue, ...]]: - """Return records plus malformed/unreadable state-file diagnostics.""" - issues: list[RegistryReadIssue] = [] - records: list[OperationRecord] = [] - for path in self._state_file_paths(): - record = _load_record(path, issues) - if record is not None: - records.append(record) - records.sort(key=lambda r: r.updated_at, reverse=True) - return tuple(records), tuple(issues) - - def get_operation(self, operation_id: str) -> OperationRecord | None: - """Return the snapshot for one operation, or ``None`` when absent.""" - operation_id = validate_operation_id(operation_id) - path = self.state_dir / f"{operation_id}.json" - if not path.exists(): - return None - return _load_record(path) - - def get_operation_diagnostic( - self, operation_id: str - ) -> tuple[OperationRecord | None, tuple[RegistryReadIssue, ...]]: - """Return one record and a typed diagnostic when its file is unreadable.""" - operation_id = validate_operation_id(operation_id) - path = self.state_dir / f"{operation_id}.json" - if not path.exists(): - return None, () - issues: list[RegistryReadIssue] = [] - return _load_record(path, issues), tuple(issues) - - def prune_completed( - self, - *, - older_than: timedelta = DEFAULT_COMPLETED_TTL, - now: datetime | None = None, - ) -> tuple[str, ...]: - """Remove state files for completed-successful operations. - - Failed operations are deliberately retained — operators need - them for diagnostics. Operations that are still running, have - no ``updated_at`` timestamp, or whose ``updated_at`` is younger - than the TTL are skipped. - - Returns the operation ids that were pruned (for logging / - tests). - """ - reference = now if now is not None else datetime.now(timezone.utc) - cutoff = reference - older_than - pruned: list[str] = [] - for path in self._state_file_paths(): - record = _load_record(path) - if record is None: - continue - if record.status is not OperationStatus.COMPLETED: - continue - updated_dt = _parse_updated_at(record.updated_at) - if updated_dt is None: - continue - # Normalize naive datetimes to UTC so the comparison is - # always between timezone-aware datetimes. - if updated_dt.tzinfo is None: - updated_dt = updated_dt.replace(tzinfo=timezone.utc) - if updated_dt > cutoff: - continue - try: - os.unlink(path) - except OSError as exc: - logger.warning( - "maintenance_registry_prune_failed", - operation_id=record.operation_id, - error=str(exc), - ) - continue - pruned.append(record.operation_id) - return tuple(pruned) - - -__all__ = [ - "DEFAULT_COMPLETED_TTL", - "MaintenanceOperationRegistry", - "OperationRecord", - "RegistryReadIssue", -] diff --git a/polylogue/maintenance/scope.py b/polylogue/maintenance/scope.py deleted file mode 100644 index 13833eca22..0000000000 --- a/polylogue/maintenance/scope.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Typed scope filters for maintenance backfill operations (issue #1196). - -Replaces the legacy free-form ``MaintenanceScope.filter`` JSON dict with -a typed :class:`MaintenanceScopeFilter` Pydantic model so every surface -(CLI ``polylogue ops maintenance plan/run``, daemon HTTP, MCP -``maintenance_preview``/``maintenance_execute``) agrees on the same -scope dimensions. - -The filter dimensions match the ones #996 AC #1 names explicitly: - -* ``session_ids`` — restrict to a specific set of session ids; -* ``origin`` — restrict to one origin token (e.g. ``"claude-code-session"``); -* ``source_family`` — restrict to one source family (e.g. - ``"claude-code-session"``); -* ``source_root`` — restrict to artifacts acquired under one runtime - root (e.g. ``~/.claude/projects``); -* ``time_range`` — inclusive ``(since, until)`` ISO-8601 window; -* ``failure_kind`` — restrict to attempts that failed with one kind; -* ``parser_version`` — restrict to one parser/materializer version. - -The filter is intentionally *target-owned* at the repair-fn boundary: -each repair fn declares which dimensions it knows how to honor and must -not advertise narrower operator behavior than it actually applies. For -example, session-insight repair honors ``session_ids``. Other dimensions -are refused at execution until a target pins their contract. - -The filter round-trips through :meth:`MaintenanceScopeFilter.to_dict` -/ :meth:`MaintenanceScopeFilter.from_dict` so the CLI ``--output-format -json``, the daemon HTTP body, and the MCP tool args all carry the -exact same shape. -""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -from typing import Any - -from pydantic import ConfigDict, field_validator - -from polylogue.core.enums import Origin -from polylogue.surfaces.payloads import SurfacePayloadModel - -HONORED_SCOPE_DIMENSIONS = frozenset(("session_ids",)) -TARGET_HONORED_SCOPE_DIMENSIONS = { - "session_insights": HONORED_SCOPE_DIMENSIONS, - "empty_sessions": HONORED_SCOPE_DIMENSIONS, - "superseded_raw_snapshots": frozenset(), -} - - -class MaintenanceScopeFilter(SurfacePayloadModel): - """Typed scope filter for a maintenance backfill operation. - - Every field is optional; ``None`` means "do not narrow on this - dimension". An entirely empty filter is the canonical full-scope - request — :func:`is_empty` returns ``True`` for that case. - - The model is frozen and forbids extra fields, so surfaces cannot - silently introduce new dimensions without an explicit change to - this contract. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - session_ids: tuple[str, ...] | None = None - origin: str | None = None - source_family: str | None = None - source_root: Path | None = None - time_range: tuple[datetime, datetime] | None = None - failure_kind: str | None = None - parser_version: str | None = None - - @field_validator("session_ids", mode="before") - @classmethod - def _coerce_session_ids(cls, value: Any) -> Any: - if value is None: - return None - if isinstance(value, str): - return (value,) - if isinstance(value, (list, tuple)): - # An empty sequence is "no session narrowing requested", not a - # scope of zero sessions: Click's ``multiple=True`` yields ``()`` - # for an omitted ``--session-id``, and a target that cannot honor - # ``session_ids`` must not refuse that default invocation. - return tuple(str(v) for v in value) or None - return value - - @field_validator("time_range", mode="before") - @classmethod - def _coerce_time_range(cls, value: Any) -> Any: - if value is None: - return None - if isinstance(value, (list, tuple)): - if len(value) != 2: - raise ValueError("time_range must be a (since, until) pair") - since, until = value - return (_coerce_datetime(since), _coerce_datetime(until)) - return value - - @field_validator("source_root", mode="before") - @classmethod - def _coerce_source_root(cls, value: Any) -> Any: - if value is None or isinstance(value, Path): - return value - return Path(str(value)) - - def is_empty(self) -> bool: - """True when no scope dimension is set (full-scope request).""" - return ( - self.session_ids is None - and self.origin is None - and self.source_family is None - and self.source_root is None - and self.time_range is None - and self.failure_kind is None - and self.parser_version is None - ) - - def to_dict(self) -> dict[str, Any]: - """Return the filter as a JSON-shaped dict. - - ``mode="json"`` coerces tuples to lists, ``Path`` to string, - and ``datetime`` to ISO-8601 strings so the result is - byte-stable across surfaces. - """ - return self.model_dump(mode="json", exclude_none=False) - - @classmethod - def from_dict(cls, payload: dict[str, Any] | None) -> MaintenanceScopeFilter: - """Reconstruct a filter from a JSON-shaped dict. - - Missing keys default to ``None``. ``None`` and ``{}`` both - round-trip to an empty filter. - """ - if payload is None or not payload: - return cls() - return cls.model_validate(payload) - - @classmethod - def from_surface_args( - cls, - *, - session_ids: tuple[str, ...] | list[str] | None = None, - origin: str | None = None, - source_family: str | None = None, - source_root: str | Path | None = None, - since: str | datetime | None = None, - until: str | datetime | None = None, - failure_kind: str | None = None, - parser_version: str | None = None, - ) -> MaintenanceScopeFilter: - """Build a filter from the common CLI/MCP argument vocabulary.""" - if (since is None) != (until is None): - raise ValueError("since and until must be supplied together") - time_range = (since, until) if since is not None and until is not None else None - return cls.model_validate( - { - "session_ids": session_ids, - "origin": Origin(origin).value if origin is not None else None, - "source_family": source_family, - "source_root": source_root, - "time_range": time_range, - "failure_kind": failure_kind, - "parser_version": parser_version, - } - ) - - -def _coerce_datetime(value: Any) -> datetime: - if isinstance(value, datetime): - return value - if isinstance(value, str): - text = value.replace("Z", "+00:00") if value.endswith("Z") else value - return datetime.fromisoformat(text) - raise TypeError(f"Cannot coerce {value!r} to datetime") - - -def unsupported_scope_dimensions(scope_filter: MaintenanceScopeFilter, *, target: str | None = None) -> tuple[str, ...]: - """Return requested dimensions the selected target does not apply.""" - honored = ( - TARGET_HONORED_SCOPE_DIMENSIONS.get(target, HONORED_SCOPE_DIMENSIONS) if target else HONORED_SCOPE_DIMENSIONS - ) - return tuple( - name - for name in ( - "session_ids", - "origin", - "source_family", - "source_root", - "time_range", - "failure_kind", - "parser_version", - ) - if getattr(scope_filter, name, None) is not None and name not in honored - ) - - -__all__ = [ - "HONORED_SCOPE_DIMENSIONS", - "MaintenanceScopeFilter", - "TARGET_HONORED_SCOPE_DIMENSIONS", - "unsupported_scope_dimensions", -] diff --git a/polylogue/maintenance/targets.py b/polylogue/maintenance/targets.py deleted file mode 100644 index 450acd26b6..0000000000 --- a/polylogue/maintenance/targets.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Canonical maintenance-target metadata shared across doctor, repair, and scenario surfaces.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from functools import lru_cache - -from polylogue.core.json import JSONDocument, json_document -from polylogue.core.outcomes import OutcomeStatus -from polylogue.maintenance.models import MaintenanceCategory - - -class MaintenanceTargetMode(str, Enum): - """User-facing maintenance action families.""" - - REPAIR = "repair" - CLEANUP = "cleanup" - - -@dataclass(frozen=True, slots=True) -class MaintenanceTargetSpec: - """One named maintenance target with stable execution and reporting semantics.""" - - name: str - mode: MaintenanceTargetMode - category: MaintenanceCategory - destructive: bool - description: str - include_preview_when_ready: bool = False - doctor_readiness_operation: str = "" - doctor_repair_operation: str = "" - include_in_archive_readiness: bool = False - archive_readiness_unready_status: OutcomeStatus | None = None - archive_readiness_requires_deep: bool = False - aliases: tuple[str, ...] = () - #: Stable invalidation-key tokens this target watches. The planner - #: matches these against schema/materializer/config snapshots to - #: decide which ``InvalidationReason`` applies. Examples: - #: ``"messages_fts"``, ``"session.profile"``, ``"embedding.voyage-4"``. - invalidation_keys: tuple[str, ...] = () - #: Whether this target can be driven through the resumable replay - #: executor (:func:`polylogue.maintenance.replay.execute_replay`). - #: ``True`` is the default and asserts a real handler exists in - #: :data:`polylogue.storage.repair.REPAIR_HANDLERS` -- the catalog - #: equality test in ``tests/unit/maintenance/test_targets.py`` fails - #: anti-vacuously if a target claims replayable without a handler, or - #: has a handler but is not advertised as replayable. - replayable: bool = True - #: Surface-visible reason a target is intentionally excluded from - #: replay (break-glass only, requires a dedicated command, etc.). - #: Required (non-empty) when ``replayable=False``. - non_replayable_reason: str = "" - - def to_dict(self) -> JSONDocument: - return json_document( - { - "name": self.name, - "mode": self.mode.value, - "category": self.category.value, - "destructive": self.destructive, - "description": self.description, - "include_preview_when_ready": self.include_preview_when_ready, - "doctor_readiness_operation": self.doctor_readiness_operation, - "doctor_repair_operation": self.doctor_repair_operation, - "include_in_archive_readiness": self.include_in_archive_readiness, - "archive_readiness_unready_status": ( - self.archive_readiness_unready_status.value if self.archive_readiness_unready_status else None - ), - "archive_readiness_requires_deep": self.archive_readiness_requires_deep, - "aliases": list(self.aliases), - "invalidation_keys": list(self.invalidation_keys), - "replayable": self.replayable, - "non_replayable_reason": self.non_replayable_reason, - } - ) - - -@dataclass(frozen=True, slots=True) -class MaintenanceTargetCatalog: - """Canonical maintenance-target registry with grouping and alias resolution helpers.""" - - specs: tuple[MaintenanceTargetSpec, ...] - - def by_name(self) -> dict[str, MaintenanceTargetSpec]: - return {spec.name: spec for spec in self.specs} - - def names(self) -> tuple[str, ...]: - return tuple(spec.name for spec in self.specs) - - def names_for_mode(self, mode: MaintenanceTargetMode) -> tuple[str, ...]: - return tuple(spec.name for spec in self.specs if spec.mode is mode) - - def resolve_name(self, name: str) -> MaintenanceTargetSpec | None: - for spec in self.specs: - if spec.name == name or name in spec.aliases: - return spec - return None - - def resolve(self, names: tuple[str, ...]) -> tuple[MaintenanceTargetSpec, ...]: - resolved: list[MaintenanceTargetSpec] = [] - seen: set[str] = set() - for name in names: - spec = self.resolve_name(name) - if spec is None or spec.name in seen: - continue - seen.add(spec.name) - resolved.append(spec) - return tuple(resolved) - - def resolve_or_default(self, names: tuple[str, ...]) -> tuple[MaintenanceTargetSpec, ...]: - """Resolve explicit target names, or expand to the run-all set. - - This is the single target-resolution behavior shared by - ``polylogue ops maintenance plan``/``run`` (CLI, via - :func:`~polylogue.maintenance.replay.execute_replay`) and the - daemon HTTP/MCP ``preview``/``execute`` routes (via - :func:`~polylogue.maintenance.planner.execute_backfill`/ - ``preview_backfill``): an empty ``names`` tuple means "no - explicit scope narrowing", which expands to every catalog - target in declared order -- the documented targetless run-all - set. An explicit but unresolvable name (e.g. a typo) still - resolves to an empty tuple so callers can distinguish "asked - for nothing in particular" from "asked for something that does - not exist". - """ - if not names: - return self.specs - return self.resolve(names) - - def replayable_names(self) -> tuple[str, ...]: - """Names of targets the catalog declares replay-capable. - - This is the catalog-side half of the replay-capability contract; - :mod:`polylogue.maintenance.replay` cross-checks these against the - real handler dict in :mod:`polylogue.storage.repair` so the two - never silently diverge again (polylogue-71ey). - """ - return tuple(spec.name for spec in self.specs if spec.replayable) - - def preview_target_names(self) -> tuple[str, ...]: - return tuple(spec.name for spec in self.specs if spec.include_preview_when_ready) - - def archive_readiness_specs(self, *, deep: bool) -> tuple[MaintenanceTargetSpec, ...]: - return tuple( - spec - for spec in self.specs - if spec.include_in_archive_readiness and (deep or not spec.archive_readiness_requires_deep) - ) - - def maintenance_targets_for_operation_names( - self, operation_names: tuple[str, ...] - ) -> tuple[MaintenanceTargetSpec, ...]: - operations = set(operation_names) - return tuple( - spec - for spec in self.specs - if spec.doctor_readiness_operation in operations or spec.doctor_repair_operation in operations - ) - - def repair_hint(self, names: tuple[str, ...], *, include_run_all: bool = False) -> str: - commands = [f"`polylogue ops doctor --repair --target {spec.name}`" for spec in self.resolve(names)] - if include_run_all: - commands.append("`polylogued run`") - if not commands: - return "Run `polylogue ops doctor --repair`." - if len(commands) == 1: - return f"Run {commands[0]}." - return f"Run {', '.join(commands[:-1])}, or {commands[-1]}." - - def doctor_readiness_operations_for_names(self, names: tuple[str, ...]) -> tuple[str, ...]: - return _unique( - tuple(spec.doctor_readiness_operation for spec in self.resolve(names) if spec.doctor_readiness_operation) - ) - - def doctor_repair_operations_for_names(self, names: tuple[str, ...]) -> tuple[str, ...]: - return _unique( - tuple(spec.doctor_repair_operation for spec in self.resolve(names) if spec.doctor_repair_operation) - ) - - def help_text(self) -> str: - names = self.names() - if not names: - return "Limit maintenance to named targets" - if len(names) == 1: - return f"Limit maintenance to named target {names[0]}" - prefix = ", ".join(names[:-1]) - return f"Limit maintenance to named targets such as {prefix}, or {names[-1]}" - - -def _unique(items: tuple[str, ...]) -> tuple[str, ...]: - seen: set[str] = set() - result: list[str] = [] - for item in items: - if item in seen: - continue - seen.add(item) - result.append(item) - return tuple(result) - - -MAINTENANCE_TARGET_SPECS: tuple[MaintenanceTargetSpec, ...] = ( - MaintenanceTargetSpec( - name="session_insights", - mode=MaintenanceTargetMode.REPAIR, - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - description="Repair or rebuild the derived session-insight read models.", - include_preview_when_ready=True, - doctor_readiness_operation="project-session-insight-readiness", - doctor_repair_operation="materialize-session-insights", - invalidation_keys=( - "session.profile", - "session.work_events", - "session.phases", - "session.threads", - "session.summaries", - ), - ), - MaintenanceTargetSpec( - name="empty_sessions", - mode=MaintenanceTargetMode.CLEANUP, - category=MaintenanceCategory.ARCHIVE_CLEANUP, - destructive=True, - description="Delete sessions with no real content (zero messages, or every message carries zero words).", - include_in_archive_readiness=True, - archive_readiness_unready_status=OutcomeStatus.WARNING, - ), - MaintenanceTargetSpec( - name="superseded_raw_snapshots", - mode=MaintenanceTargetMode.CLEANUP, - category=MaintenanceCategory.ARCHIVE_CLEANUP, - destructive=True, - description="Delete redundant live raw snapshots whose source files still exist.", - include_in_archive_readiness=True, - archive_readiness_unready_status=OutcomeStatus.WARNING, - archive_readiness_requires_deep=True, - aliases=("raw_snapshots",), - invalidation_keys=("raw_sessions",), - ), -) - - -@lru_cache(maxsize=1) -def build_maintenance_target_catalog() -> MaintenanceTargetCatalog: - return MaintenanceTargetCatalog(specs=MAINTENANCE_TARGET_SPECS) - - -#: Targets the doctor's ``--repair`` umbrella iterates by default — every -#: REPAIR-mode target in the catalog. -SAFE_REPAIR_TARGETS = build_maintenance_target_catalog().names_for_mode(MaintenanceTargetMode.REPAIR) -CLEANUP_TARGETS = build_maintenance_target_catalog().names_for_mode(MaintenanceTargetMode.CLEANUP) -MAINTENANCE_TARGET_NAMES = build_maintenance_target_catalog().names() - - -__all__ = [ - "CLEANUP_TARGETS", - "MAINTENANCE_TARGET_NAMES", - "MAINTENANCE_TARGET_SPECS", - "SAFE_REPAIR_TARGETS", - "MaintenanceTargetCatalog", - "MaintenanceTargetMode", - "MaintenanceTargetSpec", - "build_maintenance_target_catalog", -] diff --git a/polylogue/mcp/declarations/registry.py b/polylogue/mcp/declarations/registry.py index e93a88a4d4..a8fb37b1e9 100644 --- a/polylogue/mcp/declarations/registry.py +++ b/polylogue/mcp/declarations/registry.py @@ -223,19 +223,19 @@ def _compatibility(row: _ToolRow) -> CompatibilityKey: ), _ToolRow( "maintenance", - "Preview, execute, list, and inspect maintenance operations. execute with " - "dry_run=false, rebuild_index, rebuild_insights, and recovery_adjudicate require confirm=true " + "Rebuild derived indexes and inspect or adjudicate operation recovery. " + "rebuild_index, rebuild_insights, and recovery_adjudicate require confirm=true " "and fail closed without it.", "polylogue.mcp.server_cutover", "register_cutover_privileged_tools", "maintenance", MCPVerb.MAINTENANCE, - ("maintenance-plan", "maintenance-operation"), + ("maintenance-operation",), MCPResultSemantics.MAINTENANCE, "polylogue.mcp.server_cutover.maintenance:inspect.signature", - (("operation", "list"),), + (("operation", "recovery_status"),), "operation_result", - "polylogue.maintenance.planner.preview_backfill", + "mutate-rebuild-index", ), ) diff --git a/polylogue/mcp/server_cutover.py b/polylogue/mcp/server_cutover.py index 8e304ced64..6417fcd94c 100644 --- a/polylogue/mcp/server_cutover.py +++ b/polylogue/mcp/server_cutover.py @@ -2076,128 +2076,9 @@ async def _dispatch_run(hooks: ServerCallbacks, *, ref: str, limit: int | None) async def _dispatch_maintenance(hooks: ServerCallbacks, *, operation: str, kwargs: dict[str, Any]) -> str: - """Preview/execute/inspect maintenance operations, delegating to the existing planner/registry.""" - from polylogue.maintenance.envelope import envelope_from_operation - + """Dispatch derived-index and recovery maintenance operations.""" config = hooks.get_config() - if operation in ("preview", "execute"): - from polylogue.core.enums import OperationStatus - from polylogue.maintenance.planner import execute_backfill, preview_backfill - - targets = kwargs.get("targets") - session_ids = kwargs.get("session_ids") - try: - from polylogue.maintenance.scope import MaintenanceScopeFilter - - scope_filter = MaintenanceScopeFilter.from_surface_args( - session_ids=list(session_ids) if session_ids else None, - origin=kwargs.get("origin"), - source_family=kwargs.get("source_family"), - source_root=kwargs.get("source_root"), - since=kwargs.get("since"), - until=kwargs.get("until"), - failure_kind=kwargs.get("failure_kind"), - parser_version=kwargs.get("parser_version"), - ) - except ValueError as exc: - return hooks.error_json(str(exc), code="invalid_argument") - resolved_targets = tuple(targets) if targets else () - if operation == "preview": - result = preview_backfill(config, targets=resolved_targets, scope_filter=scope_filter) - envelope = envelope_from_operation(result, origin="mcp", mode="preview") - else: - dry_run = bool(kwargs.get("dry_run") or False) - if not dry_run: - confirm_error = _require_confirm( - hooks, bool(kwargs.get("confirm") or False), verb="execute maintenance with dry_run=false" - ) - if confirm_error is not None: - return confirm_error - result = execute_backfill(config, targets=resolved_targets, dry_run=dry_run, scope_filter=scope_filter) - envelope = envelope_from_operation(result, origin="mcp", mode="execute") - if result.status is OperationStatus.FAILED: - # Typed failure: surface as an MCP error payload (not a - # bare 200-shaped success envelope) while still carrying - # the operation id and first failure/error detail so a - # client can correlate against `maintenance status` - # (polylogue-71ey AC 4). - detail = result.error or ( - result.failure_samples.samples[0].message - if result.failure_samples.samples - else "no failure detail recorded" - ) - return hooks.error_json( - f"maintenance execute failed: {result.operation_id}", - code="maintenance_execute_failed", - detail=detail, - tool="maintenance", - ) - return hooks.json_payload(envelope) - - if operation == "status": - from polylogue.maintenance.registry import MaintenanceOperationRegistry - - operation_id = kwargs.get("operation_id") - if not isinstance(operation_id, str) or not operation_id: - return hooks.error_json("maintenance(operation='status') requires operation_id", code="invalid_argument") - registry = MaintenanceOperationRegistry(config=config) - record, issues = registry.get_operation_diagnostic(operation_id) - if issues: - issue = issues[0] - return hooks.error_json( - f"maintenance registry could not read {issue.path.name}", - code="maintenance_registry_degraded", - detail=issue.detail, - ) - if record is None: - if (registry.state_dir / f"{operation_id}.json").exists(): - return hooks.error_json( - f"maintenance registry record is unreadable: {operation_id}", - code="registry_degraded", - tool="maintenance", - ) - return hooks.error_json(f"Operation not found: {operation_id}", code="not_found") - envelope = envelope_from_operation(record.operation, origin="mcp", mode="execute") - return hooks.json_payload( - MCPRootPayload( - root={ - "envelope": envelope.to_dict(), - "updated_at": record.updated_at, - "state_path": str(record.state_path), - } - ) - ) - - if operation == "list": - from polylogue.maintenance.registry import MaintenanceOperationRegistry - - registry = MaintenanceOperationRegistry(config=config) - records, issues = registry.list_operations_diagnostic() - if issues: - return hooks.error_json( - "maintenance registry contains unreadable state files", - code="maintenance_registry_degraded", - detail="; ".join(f"{issue.path.name}: {issue.detail}" for issue in issues), - ) - items = [ - { - "envelope": envelope_from_operation(r.operation, origin="mcp", mode="execute").to_dict(), - "updated_at": r.updated_at, - "state_path": str(r.state_path), - } - for r in records - ] - return hooks.json_payload( - MCPRootPayload( - root={ - "items": items, - "total": len(items), - "degraded": bool(issues), - } - ) - ) - if operation == "rebuild_index": from polylogue.mcp.payloads import MCPMutationStatusPayload @@ -2555,37 +2436,21 @@ def make_item(item: dict[str, object]) -> ArchiveAssertionBulkJudgmentItemEnvelo async def maintenance( operation: Literal[ - "preview", - "execute", - "status", - "list", "rebuild_index", "update_index", "rebuild_insights", "recovery_status", "recovery_adjudicate", ], - targets: list[str] | None = None, - dry_run: bool = False, - session_ids: list[str] | None = None, - origin: str | None = None, - source_family: str | None = None, - source_root: str | None = None, - since: str | None = None, - until: str | None = None, - failure_kind: str | None = None, - parser_version: str | None = None, operation_id: str | None = None, target_outcomes: dict[str, Literal["applied", "not-applied", "unknown"]] | None = None, reason: str | None = None, confirm: bool = False, ) -> str: - """Preview, execute, list, and inspect maintenance operations. + """Rebuild derived indexes and inspect or adjudicate operation recovery. - Destructive/full-effect operations require ``confirm=True``: - ``execute`` with ``dry_run=false``, ``rebuild_index``, and - ``rebuild_insights`` and ``recovery_adjudicate`` all fail closed without it (interim - mitigation, polylogue-jn40). + Full-effect operations require ``confirm=True``: ``rebuild_index``, + ``rebuild_insights`` and ``recovery_adjudicate`` fail closed without it. """ async def run() -> str: @@ -2593,16 +2458,6 @@ async def run() -> str: hooks, operation=operation, kwargs={ - "targets": targets, - "dry_run": dry_run, - "session_ids": session_ids, - "origin": origin, - "source_family": source_family, - "source_root": source_root, - "since": since, - "until": until, - "failure_kind": failure_kind, - "parser_version": parser_version, "operation_id": operation_id, "target_outcomes": target_outcomes, "reason": reason, @@ -2610,7 +2465,7 @@ async def run() -> str: }, ) - return await hooks.async_safe_call("maintenance", run, session_ids=tuple(session_ids or ())) + return await hooks.async_safe_call("maintenance", run) register_declared_handler(mcp, maintenance, name="maintenance") diff --git a/polylogue/operations/__init__.py b/polylogue/operations/__init__.py index 318dbffe5b..de74a75a18 100644 --- a/polylogue/operations/__init__.py +++ b/polylogue/operations/__init__.py @@ -1,7 +1,7 @@ """Canonical archive operations shared across facade, CLI, and MCP surfaces. Re-exports are lazy (PEP 562 module ``__getattr__``): ``.archive`` alone pulls -in the whole insights registry (``insights.archive`` -> ``storage.repair`` and +in the whole insights registry (``insights.archive`` -> ``storage.raw_convergence`` and friends), so a caller that only needs e.g. ``OperationStatus`` from ``.operation_contract`` -- reached simply by importing a *submodule* of this package, which Python resolves by running this ``__init__`` first -- used to diff --git a/polylogue/operations/archive_debt.py b/polylogue/operations/archive_debt.py index 7c1eab17b4..7fc176f1d6 100644 --- a/polylogue/operations/archive_debt.py +++ b/polylogue/operations/archive_debt.py @@ -20,10 +20,9 @@ from polylogue.daemon.convergence_debt_status import convergence_debt_summary_info from polylogue.daemon.embedding_readiness import embedding_readiness_info from polylogue.daemon.fts_status import fts_readiness_info -from polylogue.maintenance.targets import MAINTENANCE_TARGET_NAMES from polylogue.sources.dispatch import is_stream_record_provider from polylogue.storage.introspection import table_exists as _table_exists -from polylogue.storage.repair import RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES +from polylogue.storage.raw_convergence import RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS from polylogue.storage.sqlite.archive_tiers.user_write import list_assertion_candidates from polylogue.surfaces.payloads import ( @@ -36,12 +35,6 @@ ArchiveDebtTotalsPayload, ) -_CONVERGENCE_STAGE_MAINTENANCE_TARGETS = { - "embed": "message_embeddings", - "insights": "session_insights", - "session_insights": "session_insights", -} - def archive_debt_list( *, @@ -803,7 +796,7 @@ def _convergence_rows(index_db: Path) -> list[ArchiveDebtRowPayload]: rows: list[ArchiveDebtRowPayload] = [] for item in summary.recent: subject_ref = f"{item.subject_type}:{item.subject_id}" - actions = _convergence_actions(item.stage) if item.retry_due else () + actions: tuple[ArchiveDebtActionPayload, ...] = () rows.append( ArchiveDebtRowPayload( debt_ref=f"debt:convergence:{item.stage}:{item.subject_type}:{item.subject_id}", @@ -824,18 +817,6 @@ def _convergence_rows(index_db: Path) -> list[ArchiveDebtRowPayload]: return rows -def _convergence_actions(stage: str) -> tuple[ArchiveDebtActionPayload, ...]: - target = _CONVERGENCE_STAGE_MAINTENANCE_TARGETS.get(stage) - if target is None or target not in MAINTENANCE_TARGET_NAMES: - return () - return ( - ArchiveDebtActionPayload( - label="Run maintenance", - command=("polylogue", "ops", "maintenance", "run", "--target", target), - ), - ) - - def _source_family(subject_type: str, subject_id: str) -> str: from polylogue.daemon.convergence_debt_alert import source_family_for_subject diff --git a/polylogue/operations/daemon_workload_probe.py b/polylogue/operations/daemon_workload_probe.py index 054777bdfc..79c5b6112d 100644 --- a/polylogue/operations/daemon_workload_probe.py +++ b/polylogue/operations/daemon_workload_probe.py @@ -28,7 +28,7 @@ from polylogue.storage.archive_identity import resolve_active_index_path from polylogue.storage.archive_readiness import probe_archive_tier from polylogue.storage.blob_integrity import scan_blob_reference_debt -from polylogue.storage.repair import raw_materialization_replay_backlog +from polylogue.storage.raw_convergence import raw_materialization_replay_backlog from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.connection_profile import open_readonly_connection diff --git a/polylogue/pipeline/ids.py b/polylogue/pipeline/ids.py index 0ad3b93bae..a479f4d175 100644 --- a/polylogue/pipeline/ids.py +++ b/polylogue/pipeline/ids.py @@ -20,7 +20,7 @@ # isinstance-checked here). Importing them eagerly forces the whole # `polylogue.sources` package init -- including the Drive download subsystem # -- onto every caller of this pure hashing/id module (polylogue-8s70: this -# was ~395ms of `polylogue.storage.repair`'s ~670ms import cost, the single +# was ~395ms of `polylogue.storage.raw_convergence`'s ~670ms import cost, the single # largest contributor). TYPE_CHECKING-only keeps static typing intact while # deferring the real import to whichever caller actually needs `sources`. if TYPE_CHECKING: @@ -89,7 +89,7 @@ class SessionRevisionProjection: question from revision *comparison*, and only the latter is content-only). ``attachment_identities`` is kept as a plain ``frozenset[bytes]`` of the same content-derived keys, read by - ``storage/repair.py``/``archive.py`` only via ``len()`` for a frontier + ``storage/raw_convergence.py``/``archive.py`` only via ``len()`` for a frontier count. """ diff --git a/polylogue/pipeline/services/parsing_workflow.py b/polylogue/pipeline/services/parsing_workflow.py index 84ccdfef6b..7dd8642b3a 100644 --- a/polylogue/pipeline/services/parsing_workflow.py +++ b/polylogue/pipeline/services/parsing_workflow.py @@ -341,7 +341,7 @@ async def parse_from_raw( byte-for-byte unchanged for existing callers) bounds this call's own wall-clock duration -- and therefore the writer-coordinator hold a caller (e.g. the daemon's Drive catch-up actor) runs it under. Mirrors - ``repair_raw_materialization``'s ``max_pass_seconds`` (polylogue-de2a): + ``converge_raw_materialization``'s ``max_pass_seconds`` (polylogue-de2a): elapsed time is checked only *between* raw-id batches, the same point this loop already commits and would naturally recompute a backlog on the next call -- not a mid-write yield (a mid-batch transaction cannot safely diff --git a/polylogue/readiness/__init__.py b/polylogue/readiness/__init__.py index 887edc9582..37d8c2b7eb 100644 --- a/polylogue/readiness/__init__.py +++ b/polylogue/readiness/__init__.py @@ -18,12 +18,10 @@ from polylogue.core.json import JSONDocument, json_document from polylogue.core.outcomes import OutcomeCheck, OutcomeReport, OutcomeStatus from polylogue.maintenance.models import DerivedModelStatus -from polylogue.maintenance.targets import build_maintenance_target_catalog from polylogue.readiness.capability import ( LEGACY_READINESS_SOURCE_TYPES, CapabilityReadinessState, ComponentReadiness, - component_from_archive_debt, component_from_archive_surface, component_from_assertion_substrate, component_from_catchup_status, @@ -36,10 +34,9 @@ component_from_raw_materialization_readiness, component_from_transform_registry, ) -from polylogue.storage.archive_identity import archive_file_set_root, resolve_active_index_path +from polylogue.storage.archive_identity import resolve_active_index_path from polylogue.storage.archive_readiness import claude_workflow_materialization_status, raw_materialization_ready from polylogue.storage.raw_retention import RawFrontierIntegrityProjection, raw_frontier_integrity_projection -from polylogue.storage.repair import ArchiveDebtStatus from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION # Re-export canonical types for downstream consumers. @@ -47,7 +44,6 @@ VerifyStatus = OutcomeStatus READINESS_TTL_SECONDS = 600 -_MAINTENANCE_TARGET_CATALOG = build_maintenance_target_catalog() _DERIVED_MODEL_READINESS_CHECKS: tuple[tuple[str, str], ...] = ( ("fts_sync", "messages_fts"), @@ -83,7 +79,6 @@ class ReadinessReport(OutcomeReport): timestamp: int = field(default_factory=lambda: int(time.time())) derived_models: dict[str, DerivedModelStatus] = field(default_factory=dict) - archive_debt: dict[str, ArchiveDebtStatus] = field(default_factory=dict) raw_materialization_readiness: dict[str, object] = field(default_factory=dict) raw_frontier_integrity: dict[str, object] = field(default_factory=dict) @@ -139,7 +134,6 @@ def to_dict(self) -> JSONDocument: for check in self.checks ], "derived_models": {name: status.to_dict() for name, status in sorted(self.derived_models.items())}, - "archive_debt": {name: status.to_dict() for name, status in sorted(self.archive_debt.items())}, "raw_frontier_integrity": self.raw_frontier_integrity, "summary": self.summary, } @@ -334,34 +328,6 @@ def _message_index_check(conn: sqlite3.Connection, *, exact_counts: bool) -> Rea ) -def _archive_debt_checks(archive_debt: dict[str, ArchiveDebtStatus], *, deep: bool) -> list[ReadinessCheck]: - checks: list[ReadinessCheck] = [] - for spec in _MAINTENANCE_TARGET_CATALOG.archive_readiness_specs(deep=deep): - debt = archive_debt.get(spec.name) - if debt is None: - continue - if debt.skipped: - checks.append( - ReadinessCheck( - debt.name, - VerifyStatus.SKIP, - count=debt.issue_count, - summary=debt.detail, - ) - ) - continue - unready_status = spec.archive_readiness_unready_status or VerifyStatus.WARNING - checks.append( - ReadinessCheck( - debt.name, - VerifyStatus.OK if debt.healthy else unready_status, - count=debt.issue_count, - summary=debt.detail, - ) - ) - return checks - - def _duplicate_sessions_check(conn: sqlite3.Connection) -> ReadinessCheck: # Archive invariant: the ``sessions`` table keys each session by # ``(origin, native_id)`` with a generated UNIQUE ``session_id``, so the @@ -538,40 +504,24 @@ def _transcript_embedding_checks(derived_statuses: dict[str, DerivedModelStatus] def _collect_table_status_best_effort( conn: sqlite3.Connection, *, - db_path: Path, deep: bool, probe_only: bool, - configured_root: Path | None = None, -) -> tuple[dict[str, DerivedModelStatus], dict[str, ArchiveDebtStatus]]: - """Collect derived-model and archive-debt statuses without aborting. +) -> dict[str, DerivedModelStatus]: + """Collect derived-model statuses without aborting. - These collectors still assume table layouts that may not be present in the - active archive database. When they raise ``sqlite3.OperationalError`` + The collector assumes table layouts that may not be present in the + active archive database. When it raises ``sqlite3.OperationalError`` (`no such table: ...`), continue with the integrity checks that can be answered from the opened archive. """ from polylogue.storage.derived.derived_status import collect_derived_model_statuses_sync - from polylogue.storage.repair import collect_archive_debt_statuses_sync if probe_only and not deep: - return {}, {} - + return {} try: - derived_statuses = collect_derived_model_statuses_sync(conn, verify_full=deep) + return collect_derived_model_statuses_sync(conn, verify_full=deep) except sqlite3.OperationalError: - return {}, {} - try: - archive_debt = collect_archive_debt_statuses_sync( - conn, - db_path=db_path, - derived_statuses=derived_statuses, - include_expensive=deep, - probe_only=probe_only, - configured_root=configured_root, - ) - except sqlite3.OperationalError: - archive_debt = {} - return derived_statuses, archive_debt + return {} def _claude_workflow_materialization_check(archive_root: Path) -> ReadinessCheck: @@ -683,7 +633,6 @@ def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: boo # --- derived models, debt, duplicates, providers --- derived_statuses: dict[str, DerivedModelStatus] = {} - archive_debt: dict[str, ArchiveDebtStatus] = {} db_path = _config_db_path(config) with _open_readiness_probe_connection(db_path) as conn: exact_index_counts = deep @@ -716,14 +665,7 @@ def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: boo # Run table-dependent collectors best-effort so archive integrity # probes above always register. - derived_statuses, archive_debt = _collect_table_status_best_effort( - conn, - db_path=db_path, - deep=deep, - probe_only=probe_only or not deep, - configured_root=archive_file_set_root(archive_root=archive_root, db_path=db_path), - ) - checks.extend(_archive_debt_checks(archive_debt, deep=deep)) + derived_statuses = _collect_table_status_best_effort(conn, deep=deep, probe_only=probe_only or not deep) checks.extend(_derived_model_checks(derived_statuses)) checks.extend(_transcript_embedding_checks(derived_statuses)) @@ -734,7 +676,6 @@ def run_archive_readiness(config: Config, *, deep: bool = False, probe_only: boo return ReadinessReport( checks=checks, derived_models=derived_statuses, - archive_debt=archive_debt, raw_materialization_readiness=raw_materialization_readiness, raw_frontier_integrity=raw_frontier_payload, ) @@ -1019,7 +960,6 @@ def quick_readiness_summary(archive_root: Path) -> str: "ReadinessCheck", "ReadinessReport", "VerifyStatus", - "component_from_archive_debt", "component_from_archive_surface", "component_from_assertion_substrate", "component_from_catchup_status", diff --git a/polylogue/readiness/capability.py b/polylogue/readiness/capability.py index ef4709d9bb..dc2f05ff90 100644 --- a/polylogue/readiness/capability.py +++ b/polylogue/readiness/capability.py @@ -19,7 +19,6 @@ from polylogue.core.outcomes import OutcomeCheck, OutcomeStatus from polylogue.maintenance.models import DerivedModelStatus from polylogue.operations.operation_status import OperationStatus -from polylogue.storage.repair import ArchiveDebtStatus if TYPE_CHECKING: from polylogue.storage.raw_retention import RawFrontierIntegrityProjection @@ -79,7 +78,6 @@ def to_dict(self) -> JSONDocument: "InsightReadinessReport", "InsightReadinessEntry", "SessionInsightStatusSnapshot", - "ArchiveDebtStatus", "EmbeddingStatusPayload", "DerivedModelStatus", "CatchupStatus", @@ -141,23 +139,6 @@ def component_from_derived_model(status: DerivedModelStatus, *, scope: str = "de ) -def component_from_archive_debt(status: ArchiveDebtStatus, *, scope: str = "archive_debt") -> ComponentReadiness: - if status.healthy: - state = CapabilityReadinessState.READY - elif status.skipped: - state = CapabilityReadinessState.BLOCKED - else: - state = CapabilityReadinessState.DEGRADED - return ComponentReadiness( - component=status.name, - scope=scope, - state=state, - summary=status.detail, - counts={"issue_count": status.issue_count, "destructive": status.destructive}, - repair_hint=status.maintenance_target, - ) - - def component_from_raw_materialization_readiness(readiness: Mapping[str, Any] | None) -> ComponentReadiness: payload = readiness or {} available = bool(payload.get("available", False)) @@ -913,7 +894,6 @@ def _numeric_mapping(values: Mapping[str, Any]) -> dict[str, int | float | bool] "ComponentReadiness", "LEGACY_READINESS_SOURCE_TYPES", "STATUS_SNAPSHOT_FRESHNESS_MAX_AGE_S", - "component_from_archive_debt", "component_from_archive_surface", "component_from_assertion_substrate", "component_from_catchup_status", diff --git a/polylogue/sources/__init__.py b/polylogue/sources/__init__.py index 2060031762..e4a7ee32eb 100644 --- a/polylogue/sources/__init__.py +++ b/polylogue/sources/__init__.py @@ -13,7 +13,7 @@ to run first, which used to mean eagerly pulling in the entire Google Drive subsystem (``.drive``, ``.drive.source``, ``tenacity``, ...) regardless of whether Drive is ever touched (polylogue-8s70/h1wt: this was the single -largest remaining contributor to ``polylogue.storage.repair``'s and +largest remaining contributor to ``polylogue.storage.raw_convergence``'s and ``polylogue.storage.sqlite.archive_tiers.write``'s import cost). Submodule imports (``from polylogue.sources import dispatch``) are unaffected by this ``__getattr__`` -- Python's import system falls back to importing the diff --git a/polylogue/sources/census_parse_stage.py b/polylogue/sources/census_parse_stage.py index 8c2657360c..10d4dade54 100644 --- a/polylogue/sources/census_parse_stage.py +++ b/polylogue/sources/census_parse_stage.py @@ -60,7 +60,7 @@ from polylogue.sources import revision_backfill from polylogue.sources.dispatch import is_stream_record_provider from polylogue.sources.revision_backfill import RawParsePrefetchCache -from polylogue.storage.repair import ( +from polylogue.storage.raw_convergence import ( raw_materialization_pending_census_raw_ids, raw_materialization_readonly_descriptors, ) @@ -145,7 +145,7 @@ def _resolve_readonly_native_ids(archive_root: Path, raw_ids: Sequence[str]) -> polylogue-6lyh1: mirrors ``ArchiveStore.raw_native_id`` (same column, same "blank means unknown" contract) but over a plain ``mode=ro`` connection, the same relationship ``raw_materialization_readonly_descriptors`` (in - ``storage/repair.py``) has to ``ArchiveStore.raw_revision_descriptor`` -- + ``storage/raw_convergence.py``) has to ``ArchiveStore.raw_revision_descriptor`` -- kept as a small dedicated query here rather than widening that shared helper's return shape, since its other callers do not need this column. """ @@ -581,7 +581,7 @@ def _warm_impl( Returns the number of raws newly admitted to the cache. Read-only end to end: candidate discovery and descriptor lookup both open - ``mode=ro`` SQLite connections (``polylogue.storage.repair``); + ``mode=ro`` SQLite connections (``polylogue.storage.raw_convergence``); parsing reads only already-published blob bytes via a stateless ``ArchiveBlobPublisher``, mirroring the production census parse worker exactly (``census_parse_worker``, the same function the diff --git a/polylogue/sources/origin_specs.py b/polylogue/sources/origin_specs.py index 0b4ec4befb..b8f381b2f7 100644 --- a/polylogue/sources/origin_specs.py +++ b/polylogue/sources/origin_specs.py @@ -108,7 +108,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AsyncFunctio _MATERIALIZER_FINGERPRINT_PATHS: tuple[str, ...] = ( - "polylogue/storage/repair.py", + "polylogue/storage/raw_convergence.py", "polylogue/storage/insights/session/rebuild.py", "polylogue/storage/insights/session/threads.py", "polylogue/storage/insights/session/profiles.py", diff --git a/polylogue/sources/revision_backfill.py b/polylogue/sources/revision_backfill.py index 9b705a0ba2..b52cca46ea 100644 --- a/polylogue/sources/revision_backfill.py +++ b/polylogue/sources/revision_backfill.py @@ -1023,7 +1023,7 @@ def uncensused_historical_revision_raw_ids( observed by a real parser?", which a fingerprint bump alone does not change -- only ``classify_membership_revisions`` semantics changing (a superseded fingerprint) can make a *verdict* stale, which is a separate - question the terminal-decision check in ``storage/repair.py`` answers. + question the terminal-decision check in ``storage/raw_convergence.py`` answers. Treating a bump as forcing full re-census here would mean every fingerprint bump re-parses the entire archive just to re-confirm facts that did not change. @@ -2384,7 +2384,7 @@ def commit_replay_unit() -> None: # requires enough cohorts to amortize the worker's setup (thread # spawn, two read connections, a plan scan over raw_sessions/ # raw_session_memberships): the live raw-materialization path - # (storage/repair.py) replays ONE authority component per call, + # (storage/raw_convergence.py) replays ONE authority component per call, # where a prefetcher could never get ahead of the writer anyway. effective_pipeline_decode = ( pipeline_decode @@ -3313,7 +3313,7 @@ def _replay_retained_codex_state_evidence(archive: ArchiveStore, raw_id: str) -> #: engage. Below this, the worker's fixed setup cost (thread spawn, two read #: connections, a raw_sessions/raw_session_memberships plan scan) cannot be #: repaid -- most notably the live raw-materialization path -#: (``storage/repair.py``), which replays exactly one authority component +#: (``storage/raw_convergence.py``), which replays exactly one authority component #: per ``backfill_historical_revision_evidence`` call. Explicit #: ``pipeline_decode=True``/``False`` bypasses this floor entirely. _PIPELINE_DECODE_MIN_COHORTS: Final[int] = 8 diff --git a/polylogue/storage/insights/session/rebuild.py b/polylogue/storage/insights/session/rebuild.py index 9899aa3ac3..2a0cf2d52d 100644 --- a/polylogue/storage/insights/session/rebuild.py +++ b/polylogue/storage/insights/session/rebuild.py @@ -2253,7 +2253,7 @@ def rebuild_archive_session_insights( to the canonical path, which commits internally. Both :mod:`polylogue.api` (the async facade) and - :mod:`polylogue.storage.repair` (maintenance/doctor repair orchestration) + :mod:`polylogue.storage.raw_convergence` (maintenance/doctor repair orchestration) call this primitive downward instead of duplicating it or reaching across ring boundaries for a private symbol (polylogue-exb). """ diff --git a/polylogue/storage/insights/session/runtime.py b/polylogue/storage/insights/session/runtime.py index 83df9ae2eb..20947acd6c 100644 --- a/polylogue/storage/insights/session/runtime.py +++ b/polylogue/storage/insights/session/runtime.py @@ -49,7 +49,7 @@ def session_profile_stale_predicate( Single source of truth for the sort-key staleness comparison, shared by the daemon converger (``daemon/convergence_stages.py``), ops repair - (``storage/repair.py``), and the repair-candidate prefilter + (``storage/raw_convergence.py``), and the candidate prefilter (``storage/insights/session/status.py``) — see polylogue-a7xr.2. ``sessions_alias.sort_key_ms`` is milliseconds; ``profile_alias`` caches it diff --git a/polylogue/storage/raw_authority.py b/polylogue/storage/raw_authority.py index e6dfa448bc..f698133950 100644 --- a/polylogue/storage/raw_authority.py +++ b/polylogue/storage/raw_authority.py @@ -30,7 +30,7 @@ #: later, deliberately-corrected version of ``classify_membership_revisions`` #: (polylogue-9dxn). A persisted ``ambiguous`` verdict recorded under one of #: these fingerprints is stale, not authoritative -- the terminal-decision -#: check in ``storage/repair.py`` treats it as replayable instead of durable +#: check in ``storage/raw_convergence.py`` treats it as replayable instead of durable #: debt. A verdict recorded under the CURRENT fingerprint, or with no census #: row at all (never independently confirmed which parser produced it), #: stays terminal -- absent evidence must default to conservative, not to @@ -838,7 +838,7 @@ def raw_replay_plan_deferred_for_envelope(archive_root: Path, *, max_payload_byt def raw_replay_plan_no_progress_plan_ids(archive_root: Path) -> set[str]: """Return plan ids whose most recent selected execution made zero typed progress. - hjpx's core execution-completeness gap: ``repair_raw_materialization`` can + hjpx's core execution-completeness gap: ``converge_raw_materialization`` can classify a raw as a replayable/selected authority component, hand it to ``backfill_historical_revision_evidence``, and get back ``replayed_logical_sources=0`` with no quarantine or adoption-deferral @@ -2190,13 +2190,13 @@ def auto_resolve_stale_plan_blockers(archive_root: Path) -> int: crash-recovery path (:func:`recover_interrupted_raw_authority_censuses`). This closes the actual harm a stale-plan blocker causes today: - ``unresolved_raw_replay_blockers`` (the gate ``repair_materialization`` + ``unresolved_raw_replay_blockers`` (the gate ``converge_materialization`` checks every pass) counts ANY unresolved stale-plan blocker archive-wide and, if nonzero, skips repair for every other raw too -- so one stale plan on one component halted ordinary materialization for the whole archive until someone ran the manual CLI with a throwaway acknowledgment string. Called from the daemon's own periodic - materialization loop before ``repair_materialization``, so this replaces + materialization loop before ``converge_materialization``, so this replaces "wait indefinitely for an operator" with "clear automatically on the very next pass" -- the write is a plain tombstone-and-resolve, not a re-materialization, so a component whose recomputed plan is still not diff --git a/polylogue/storage/repair.py b/polylogue/storage/raw_convergence.py similarity index 82% rename from polylogue/storage/repair.py rename to polylogue/storage/raw_convergence.py index e9980ad788..264117346b 100644 --- a/polylogue/storage/repair.py +++ b/polylogue/storage/raw_convergence.py @@ -1,4 +1,10 @@ -"""Consolidated archive repair: orphan detection, FTS repair, session insights, WAL.""" +"""Raw observation convergence: the daemon-owned source->index materialization drain. + +Holds the bounded raw materialization conveyor (``converge_raw_materialization``), +its whale-pass escalation and backlog census, and the raw-authority frontier +strategies ``storage.raw_reconciler`` executes. Ordinary daemon convergence +(``daemon/cli.py``) is the only caller that writes. +""" from __future__ import annotations @@ -8,7 +14,7 @@ import re import sqlite3 import time -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Mapping, Sequence from contextlib import closing from dataclasses import dataclass, field from datetime import UTC, datetime @@ -35,26 +41,10 @@ ) from polylogue.core.sources import origin_from_provider, origin_provider_fiber, provider_from_origin from polylogue.logging import get_logger -from polylogue.maintenance.models import DerivedModelStatus, MaintenanceCategory -from polylogue.maintenance.offline_guard import offline_maintenance_block_reason -from polylogue.maintenance.scope import MaintenanceScopeFilter, unsupported_scope_dimensions -from polylogue.maintenance.targets import ( - CLEANUP_TARGETS, - SAFE_REPAIR_TARGETS, - MaintenanceTargetSpec, - build_maintenance_target_catalog, -) from polylogue.pipeline.ids import session_content_hash, session_revision_projection from polylogue.pipeline.ids import session_id as make_session_id -from polylogue.storage.archive_identity import archive_file_set_root, resolve_active_index_path +from polylogue.storage.archive_identity import archive_file_set_root from polylogue.storage.blob_store import BlobStore -from polylogue.storage.insights.session.repair_assessment import ( - assess_session_insight_repairs, -) -from polylogue.storage.insights.session.runtime import ( - SESSION_INSIGHT_MATERIALIZATION_TYPES, - session_profile_stale_predicate, -) from polylogue.storage.raw_authority import ( RAW_AUTHORITY_PARSER_FINGERPRINT, RAW_REPLAY_NO_PROGRESS_REASON, @@ -98,8 +88,6 @@ from polylogue.sources.revision_backfill import RawParsePrefetchCache logger = get_logger(__name__) -_MAINTENANCE_TARGET_CATALOG = build_maintenance_target_catalog() -_PROBE_ONLY_EXACT_MESSAGE_ROW_LIMIT = 100_000 RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES = 1024 * 1024 * 1024 RAW_MATERIALIZATION_RESOURCE_BLOCK_REASON = "non-stream-safe raw payload exceeds the bounded replay limit" RAW_MATERIALIZATION_CENSUS_COMPONENT_LIMIT = 25 @@ -3364,113 +3352,6 @@ def inspect_browser_canonical_authority_conflicts( ) -def record_browser_canonical_authority_conflict_blockers( - config: Config, raw_ids: list[str], *, now_ms: int | None = None -) -> tuple[BrowserCanonicalAuthorityConflictReport, tuple[str, ...]]: - """Persist each unresolved conflict as a durable, non-injected ``BLOCKER`` candidate. - - This is the explicit, separate step the lkrc.3 design requires: it never - picks an authority itself. Each conflict becomes one - ``AssertionKind.BLOCKER`` candidate assertion in ``user.db``, keyed by a - deterministic id over the raw id and its evidence digest so re-running the - census after new evidence appears creates a new row instead of silently - overwriting the old one, while re-running it unchanged is idempotent. Like - every other automated writer through ``upsert_assertion``, the row is - written ``author_kind="detector"`` and is therefore always forced to - ``status=candidate`` with ``inject: false`` -- it can never self-promote to - an authoritative, context-injectable claim; an operator must explicitly - judge it (mirrors ``upsert_pathology_findings_as_assertions``, #2383). - - This function never touches authoritative identity state -- it - writes exactly one ``candidate``/non-injected/private assertion, through - ``upsert_assertion``'s single write chokepoint, which already refuses to - resurrect a judged-terminal row (accepted/rejected/deferred/superseded) - back to candidate on a later automated write (see that function's - docstring). Concretely: (1) the row can never be read as an authoritative - claim (``inject: false``, ``promotion_required: true``); (2) an operator's - judgment on a previously-recorded blocker is never silently clobbered by a - re-run, even one racing this same function; (3) re-running with unchanged - evidence is a no-op (deterministic id), and re-running after new evidence - creates a new row rather than mutating the old one. This is the same - write-safety posture as every other detector-authored candidate writer in - this codebase (``upsert_pathology_findings_as_assertions``, - ``digest``-derived ``TRANSFORM_CANDIDATE`` rows) -- none of which carry an - apply flag either. Adding one here would be ceremony without a - corresponding risk to gate. - """ - from polylogue.storage.sqlite.archive_tiers.user_write import ( - AssertionKind, - AssertionStatus, - AssertionVisibility, - read_assertion_envelope, - upsert_assertion, - ) - - report = inspect_browser_canonical_authority_conflicts(config, raw_ids) - archive_root = _raw_materialization_archive_root(config) - user_db = archive_root / "user.db" - if not user_db.exists(): - raise RuntimeError("user tier is not initialized") - timestamp = now_ms if now_ms is not None else int(time.time() * 1000) - scope_ref = "insight:browser-canonical-authority-conflict@v1" - assertion_ids: list[str] = [] - conn = sqlite3.connect(user_db) - conn.row_factory = sqlite3.Row - try: - for item in report.items: - if item.session_id is None or item.evidence_digest is None: - continue - assertion_id = hashlib.sha256( - f"assertion-blocker-browser-canonical-authority-conflict\0{item.raw_id}\0{item.evidence_digest}".encode() - ).hexdigest() - full_assertion_id = f"blocker:{assertion_id}" - existing = read_assertion_envelope(conn, full_assertion_id) - if existing is not None and existing.status != AssertionStatus.CANDIDATE: - # Mirror ``upsert_pathology_findings_as_assertions``: once an - # operator has judged a blocker (accepted/rejected/deferred/ - # superseded), a re-run over unchanged evidence must not - # overwrite its display fields (value/body_text/evidence_refs - # are plain ``ON CONFLICT DO UPDATE`` columns in - # ``upsert_assertion`` -- only ``status`` itself is protected - # by that function's terminal-judgment chokepoint). Leave the - # judged row exactly as the operator left it. - assertion_ids.append(existing.assertion_id) - continue - envelope = upsert_assertion( - conn, - assertion_id=full_assertion_id, - scope_ref=scope_ref, - target_ref=f"session:{item.session_id}", - key=f"raw/{item.raw_id}", - kind=AssertionKind.BLOCKER, - value={ - "raw_id": item.raw_id, - "canonical_logical_source_key": item.canonical_logical_source_key, - "unknown_raw_content_hash": item.unknown_raw_content_hash, - "unknown_raw_message_count": item.unknown_raw_message_count, - "competing_raw_id": item.competing_raw_id, - "competing_content_hash": item.competing_content_hash, - "competing_frontier_kind": item.competing_frontier_kind, - "competing_decision": item.competing_decision, - "competing_message_count": item.competing_message_count, - "divergent_message_index": item.divergent_message_index, - "reason": item.reason, - }, - body_text=item.divergence_note or item.reason, - author_ref=scope_ref, - author_kind="detector", - status=AssertionStatus.CANDIDATE, - visibility=AssertionVisibility.PRIVATE, - context_policy={"inject": False, "promotion_required": True}, - now_ms=timestamp, - ) - assertion_ids.append(envelope.assertion_id) - conn.commit() - finally: - conn.close() - return report, tuple(assertion_ids) - - # --- polylogue-t0dy: reconcile pre-#2729 duplicate-raw scheme --- @@ -4321,7 +4202,7 @@ def raw_materialization_pending_census_raw_ids( polylogue-m6tp phase (a): the daemon's parse-stage warmer calls this BEFORE taking the writer hold, to know which raws to pre-parse. Reuses the exact same candidate + uncensused-receipt filters as - ``repair_raw_materialization``'s own census phase, and (like + ``converge_raw_materialization``'s own census phase, and (like ``_raw_materialization_parser_census_candidates``) opens only ``mode=ro`` connections -- no write connection, and thus no writer hold, is ever required or taken here. @@ -4534,7 +4415,7 @@ def raw_materialization_whale_pass_candidate( Returns the component's seed raw id (the same "one seed expands to the whole logical component via membership" convention - ``repair_raw_materialization``'s ``raw_artifact_id`` scoping already + ``converge_raw_materialization``'s ``raw_artifact_id`` scoping already uses), or ``None`` if no component currently qualifies. Opens only ``mode=ro`` connections -- safe to call without the writer hold, exactly like ``raw_materialization_pending_census_raw_ids``. @@ -4976,7 +4857,7 @@ def raw_materialization_replay_backlog( ) -> dict[str, object]: """Return a read-only weighted backlog for raw source-to-index replay. - The report uses the same candidate selector as ``repair_raw_materialization`` + The report uses the same candidate selector as ``converge_raw_materialization`` so diagnostics and actual replay agree about which raw rows are actionable. It does not parse raw blobs or mutate the archive. """ @@ -5085,105 +4966,6 @@ def raw_materialization_replay_backlog( } -def _histogram_upper_bound(value: int) -> int: - """Return the inclusive power-of-two bucket for a non-negative value.""" - upper_bound = 1 - while upper_bound < max(value, 1): - upper_bound *= 2 - return upper_bound - - -def _histogram(values: Sequence[int], *, field: str) -> list[dict[str, int]]: - """Summarize numeric frontier shape without retaining identifying rows.""" - buckets: dict[int, int] = {} - for value in values: - upper_bound = _histogram_upper_bound(value) - buckets[upper_bound] = buckets.get(upper_bound, 0) + 1 - return [{field: upper_bound, "count": count} for upper_bound, count in sorted(buckets.items())] - - -def _backlog_count(backlog: Mapping[str, object], field: str) -> int: - """Read a bounded numeric backlog field without trusting an untyped dict.""" - value = backlog.get(field) - if isinstance(value, int) and not isinstance(value, bool): - return value - raise RuntimeError(f"raw materialization backlog returned a non-integral {field!r}: {value!r}") - - -def raw_materialization_scale_profile(config: Config) -> dict[str, object]: - """Return a private-free authority-frontier shape for synthetic proof input. - - The profile deliberately exposes counts and distributions only. It is safe - to retain with a synthetic workload receipt because it never includes raw - ids, source paths, blob hashes, or payload-derived fields. - """ - candidates = _raw_materialization_candidate_ids(config) - backlog = raw_materialization_replay_backlog(config, limit=0, _candidates=candidates) - if not bool(backlog["available"]): - return {"available": False, "reason": backlog["reason"]} - component_raw_counts = [len(component) for component in candidates.authority_components] - candidate_ids = set(candidates.raw_ids) - component_cohorts: dict[tuple[int, int], int] = {} - component_byte_cohorts: dict[tuple[int, int, int], int] = {} - for component in candidates.authority_components: - raw_count = len(component) - direct_candidate_count = len(candidate_ids.intersection(component)) - key = (raw_count, direct_candidate_count) - component_cohorts[key] = component_cohorts.get(key, 0) + 1 - component_blob_bytes = [ - sum(_raw_materialization_component_blob_bytes(candidates, raw_id) for raw_id in component) - for component in candidates.authority_components - ] - for component, blob_bytes in zip(candidates.authority_components, component_blob_bytes, strict=True): - byte_key = ( - len(component), - len(candidate_ids.intersection(component)), - _histogram_upper_bound(blob_bytes), - ) - component_byte_cohorts[byte_key] = component_byte_cohorts.get(byte_key, 0) + 1 - return { - "available": True, - "format": "raw-authority-scale-profile-v1", - "candidate_count": _backlog_count(backlog, "candidate_count"), - "expanded_candidate_count": _backlog_count(backlog, "expanded_candidate_count"), - "authority_component_count": _backlog_count(backlog, "authority_component_count"), - "executable_authority_component_count": _backlog_count(backlog, "executable_authority_component_count"), - "blocked_authority_component_count": _backlog_count(backlog, "blocked_authority_component_count"), - "total_blob_bytes": _backlog_count(backlog, "total_blob_bytes"), - "expanded_total_blob_bytes": _backlog_count(backlog, "expanded_total_blob_bytes"), - "component_raw_count_histogram": _histogram(component_raw_counts, field="upper_bound_raw_count"), - "component_cohort_distribution": [ - { - "component_raw_count": raw_count, - "direct_candidate_count": direct_candidate_count, - "component_count": count, - } - for (raw_count, direct_candidate_count), count in sorted(component_cohorts.items()) - ], - "component_byte_cohort_distribution": [ - { - "component_raw_count": raw_count, - "direct_candidate_count": direct_candidate_count, - "upper_bound_blob_bytes": upper_bound_blob_bytes, - "component_count": count, - } - for (raw_count, direct_candidate_count, upper_bound_blob_bytes), count in sorted( - component_byte_cohorts.items() - ) - ], - "component_blob_bytes_histogram": _histogram(component_blob_bytes, field="upper_bound_blob_bytes"), - "residual_state_counts": { - "missing_blob_count": _backlog_count(backlog, "missing_blob_count"), - "authority_quarantined_count": _backlog_count(backlog, "authority_quarantined_count"), - "byte_authority_fragment_count": _backlog_count(backlog, "byte_authority_fragment_count"), - "byte_authority_quarantined_count": _backlog_count(backlog, "byte_authority_quarantined_count"), - "byte_authority_pending_count": _backlog_count(backlog, "byte_authority_pending_count"), - "adoption_deferred_count": _backlog_count(backlog, "adoption_deferred_count"), - "blocked_candidate_count": _backlog_count(backlog, "blocked_candidate_count"), - }, - } - - def _raw_materialized_by_source_path_native(materialized_aliases: set[tuple[str, str]], row: sqlite3.Row) -> bool: origin = str(row["provider_origin"] or "") if not origin: @@ -5231,249 +5013,11 @@ def _source_path_native_id_candidates(source_path: str) -> tuple[str, ...]: return tuple(candidates) -def _open_archive_index_connection() -> sqlite3.Connection: - from polylogue.paths import archive_root - - conn = sqlite3.connect(resolve_active_index_path(archive_root())) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys = ON") - return conn - - -def _resolve_convergence_debt( - *, - ops_db: Path, - stage: str, - target_type: str, - target_id: str, -) -> None: - """Best-effort resolution for ops-tier convergence debt. - - Maintenance targets are explicit convergence actuators. When one proves a - target ready, stale daemon debt for the same target must stop appearing as - actionable work. - """ - if not ops_db.exists(): - return - try: - with closing(sqlite3.connect(ops_db)) as conn: - table_exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='convergence_debt'" - ).fetchone() - if not table_exists: - return - conn.execute( - """ - DELETE FROM convergence_debt - WHERE stage = ? AND target_type = ? AND target_id = ? - """, - (stage, target_type, target_id), - ) - conn.commit() - except sqlite3.Error as exc: - logger.warning( - "convergence_debt_resolve_failed", - stage=stage, - target_type=target_type, - target_id=target_id, - error=str(exc), - ) - - -def _resolve_session_insight_convergence_debt( - *, - ops_db: Path, - session_ids: tuple[str, ...] | None, -) -> None: - """Clear proven session-insight convergence debt after maintenance repair.""" - if not ops_db.exists(): - return - try: - with closing(sqlite3.connect(ops_db)) as conn: - table_exists = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='convergence_debt'" - ).fetchone() - if not table_exists: - return - if session_ids is None: - conn.execute( - """ - DELETE FROM convergence_debt - WHERE stage = 'insights' - AND target_type = 'session_id' - """ - ) - else: - for session_id in session_ids: - conn.execute( - """ - DELETE FROM convergence_debt - WHERE stage = 'insights' - AND target_type = 'session_id' - AND target_id = ? - """, - (session_id,), - ) - conn.commit() - except sqlite3.Error as exc: - logger.warning( - "session_insight_convergence_debt_resolve_failed", - session_ids=session_ids, - error=str(exc), - ) - - -def _session_insight_materializer_version() -> int: - from polylogue.storage.runtime import SESSION_INSIGHT_MATERIALIZER_VERSION - - return SESSION_INSIGHT_MATERIALIZER_VERSION - - -def _session_insight_requires_archive_wide_rebuild(status: object) -> bool: - return any( - int(getattr(status, attr, 0) or 0) > 0 - for attr in ( - "orphan_profile_row_count", - "orphan_latency_profile_row_count", - "orphan_work_event_inference_count", - "orphan_phase_inference_count", - "stale_day_summary_count", - ) - ) - - -def _session_insight_aggregate_debt_count(status: object) -> int: - return sum( - int(getattr(status, attr, 0) or 0) - for attr in ( - "missing_thread_materialization_count", - "stale_thread_count", - "orphan_thread_count", - "stale_tag_rollup_count", - "stale_day_summary_count", - ) - ) - - -def _targeted_session_insight_rebuild_ids( - conn: sqlite3.Connection | None, - status: object, -) -> tuple[str, ...] | None: - if conn is None or _session_insight_requires_archive_wide_rebuild(status): - return None - - materialization_selects = "\nUNION\n".join( - """ - SELECT s.session_id - FROM sessions AS s - WHERE NOT EXISTS ( - SELECT 1 - FROM insight_materialization AS m - WHERE m.insight_type = ? - AND m.session_id = s.session_id - AND m.materializer_version = ? - AND ABS(COALESCE(m.source_sort_key_ms, 0) - COALESCE(s.sort_key_ms, 0)) = 0 - ) - """ - for _insight_type in SESSION_INSIGHT_MATERIALIZATION_TYPES - ) - materializer_version = _session_insight_materializer_version() - profile_stale_predicate = session_profile_stale_predicate("s", "p") - latency_stale_predicate = session_profile_stale_predicate("s", "lp") - rows = conn.execute( - f""" - SELECT DISTINCT session_id - FROM ( - SELECT s.session_id - FROM sessions AS s - WHERE NOT EXISTS ( - SELECT 1 FROM session_profiles AS p WHERE p.session_id = s.session_id - ) - UNION - SELECT s.session_id - FROM sessions AS s - JOIN session_profiles AS p ON p.session_id = s.session_id - WHERE p.materializer_version != ? - OR {profile_stale_predicate} - UNION - SELECT p.session_id - FROM session_profiles AS p - JOIN sessions AS s ON s.session_id = p.session_id - WHERE NOT EXISTS ( - SELECT 1 FROM session_latency_profiles AS lp WHERE lp.session_id = p.session_id - ) - UNION - SELECT lp.session_id - FROM session_latency_profiles AS lp - JOIN sessions AS s ON s.session_id = lp.session_id - WHERE lp.materializer_version != ? - OR {latency_stale_predicate} - UNION - SELECT p.session_id - FROM session_profiles AS p - WHERE p.work_event_count != ( - SELECT COUNT(*) FROM session_work_events AS e WHERE e.session_id = p.session_id - ) - UNION - SELECT p.session_id - FROM session_profiles AS p - WHERE p.phase_count != ( - SELECT COUNT(*) FROM session_phases AS ph WHERE ph.session_id = p.session_id - ) - UNION - {materialization_selects} - ) - ORDER BY session_id - """, - ( - materializer_version, - materializer_version, - *( - value - for insight_type in SESSION_INSIGHT_MATERIALIZATION_TYPES - for value in (insight_type, materializer_version) - ), - ), - ).fetchall() - return tuple(str(row["session_id"] if isinstance(row, sqlite3.Row) else row[0]) for row in rows) - - -def _archive_index_present(config: Config) -> bool: - index_db = config.archive_root / "index.db" - if not index_db.exists(): - return False - try: - with closing(sqlite3.connect(f"file:{index_db}?mode=ro", uri=True)) as conn: - version = int(conn.execute("PRAGMA user_version").fetchone()[0] or 0) - except sqlite3.Error: - return False - return version > 0 - - -def offline_maintenance_blockers( - config: Config, - *, - repair: bool, - cleanup: bool, - dry_run: bool, - targets: tuple[str, ...] = (), -) -> list[RepairResult]: - detail = offline_maintenance_block_reason(config, active=repair or cleanup, dry_run=dry_run) - if detail is None: - return [] - selected_targets = targets or tuple(SAFE_REPAIR_TARGETS if repair else ()) + tuple( - CLEANUP_TARGETS if cleanup else () - ) - return [ - _repair_result(target_name, repaired_count=0, success=False, detail=detail) for target_name in selected_targets - ] - - @dataclass -class RepairResult: +class RawConvergenceResult: + """Typed outcome of one bounded raw materialization pass.""" + name: str - category: MaintenanceCategory - destructive: bool repaired_count: int success: bool detail: str = "" @@ -5484,8 +5028,6 @@ class RepairResult: def to_dict(self) -> JSONDocument: payload: dict[str, object] = { "name": self.name, - "category": self.category.value, - "destructive": self.destructive, "repaired_count": self.repaired_count, "success": self.success, "detail": self.detail, @@ -5519,150 +5061,7 @@ def to_dict(self) -> JSONDocument: return json_document(payload) -# --------------------------------------------------------------------------- -# Archive debt count queries (formerly archive_debt_counts) -# --------------------------------------------------------------------------- - - -def count_empty_sessions_sync(conn: sqlite3.Connection, *, session_ids: tuple[str, ...] | None = None) -> int: - """Count session rows that are debris: no *content* (zero messages, or - every message carries zero words -- see ``_empty_session_candidate_ids``) - AND a raw artifact the current classifier positively refuses to admit as - a session. - - This deliberately does NOT count every message-less session, and it does - NOT use ``raw_id IS NULL`` as the discriminator either (both were tried - and refuted -- see polylogue-ne6k). A session can be legitimately empty - (the 2026-07-22 hook-inflation postmortem explicitly chose to retain ~832 - such sessions after de-inflation), and measured on the live archive - 2026-07-29/31, every one of the 5,257 message-less sessions carries a - non-empty ``raw_id`` -- so "no acquired bytes" cannot separate the 4,945+ - genuine phantoms (``.meta`` sidecars, misclassified non-transcript - artifacts) from the rest. The only discriminator that has evidence behind - it is WHAT THE ACQUIRED ARTIFACT IS: re-running each candidate's raw bytes - through the same ``classify_artifact``/``inspect_raw_artifact`` pipeline - live ingest uses, and counting only the ones it still refuses. - - polylogue-21qj widened the candidate set beyond literally zero messages: - the live archive's ``claude-code-session:conversation_relationships`` - phantom (a sinex graph-edge index misclassified as a session before - #3428) has 96,748 *message rows*, every one carrying zero blocks/words - (``session.word_count = 0``) -- a "no real content" session that the - original "no messages at all" predicate could never see. Broadening the - predicate to ``word_count = 0`` does not risk sweeping in a legitimate - all-tool-use session with no text turns: any such session's raw artifact - still carries genuine Claude Code envelope markers and is still admitted - by ``inspect_raw_artifact``, so the classifier gate below continues to - retain it. Only artifacts that positively fail current classification are - ever deleted, regardless of which candidate query found them. - - Kept in lockstep with ``repair_empty_sessions``: the counter and the - deleter must agree, or the report says one thing and the repair does - another (both call ``_empty_session_debris_session_ids``). - - ``session_ids`` narrows the count to exactly the requested sessions, the - same narrowing ``repair_empty_sessions`` applies, so a scoped preview and - a scoped execution report the same rows. - """ - return len(_empty_session_debris_session_ids(conn, session_ids=session_ids)) - - -def _table_has_more_than(conn: sqlite3.Connection, table_name: str, row_limit: int) -> bool: - row = conn.execute(f"SELECT 1 FROM {table_name} LIMIT 1 OFFSET ?", (max(0, row_limit),)).fetchone() - return row is not None - - -# --------------------------------------------------------------------------- -# Derived repair count helpers (formerly archive_debt_repairs) -# --------------------------------------------------------------------------- - - -def session_insight_repair_count(derived_statuses: dict[str, DerivedModelStatus]) -> int: - keys = [ - "session_profile_rows", - "session_work_events", - "session_work_events_fts", - "session_phases", - "threads", - "session_tag_rollups", - ] - maybe_statuses = [derived_statuses.get(k) for k in keys] - if not all(status is not None for status in maybe_statuses): - return 0 - statuses = [status for status in maybe_statuses if status is not None] - total = 0 - for s in statuses: - total += max(0, int(s.pending_documents or 0)) - total += max(0, int(s.pending_rows or 0)) - total += max(0, int(s.stale_rows or 0)) - total += max(0, int(s.orphan_rows or 0)) - return total - - -# --------------------------------------------------------------------------- -# Archive debt collection (formerly archive_debt.py) -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ArchiveDebtStatus: - """Simple debt/orphan status for a single maintenance target.""" - - name: str - category: MaintenanceCategory - destructive: bool - issue_count: int - detail: str - maintenance_target: str - skipped: bool = False - - @property - def healthy(self) -> bool: - return self.issue_count == 0 and not self.skipped - - def to_dict(self) -> JSONDocument: - return json_document( - { - "name": self.name, - "category": self.category.value, - "destructive": self.destructive, - "issue_count": self.issue_count, - "detail": self.detail, - "maintenance_target": self.maintenance_target, - "healthy": self.healthy, - "skipped": self.skipped, - } - ) - - -def _maintenance_target_spec(name: str) -> MaintenanceTargetSpec: - spec = _MAINTENANCE_TARGET_CATALOG.resolve_name(name) - if spec is None: - raise KeyError(f"Unknown maintenance target: {name}") - return spec - - -def _repair_result( - target_name: str, - *, - repaired_count: int, - success: bool, - detail: str, - metrics: dict[str, float] | None = None, -) -> RepairResult: - spec = _maintenance_target_spec(target_name) - return RepairResult( - name=spec.name, - category=spec.category, - destructive=spec.destructive, - repaired_count=repaired_count, - success=success, - detail=detail, - metrics=dict(metrics or {}), - ) - - -def _internal_derived_repair_result( +def _raw_convergence_result( name: str, *, repaired_count: int, @@ -5671,11 +5070,9 @@ def _internal_derived_repair_result( metrics: dict[str, float] | None = None, plan_outcomes: tuple[RawReplayPlanOutcome, ...] = (), census_receipt: RawAuthorityCensusReceipt | None = None, -) -> RepairResult: - return RepairResult( +) -> RawConvergenceResult: + return RawConvergenceResult( name=name, - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, repaired_count=repaired_count, success=success, detail=detail, @@ -5685,9 +5082,9 @@ def _internal_derived_repair_result( ) -def raw_materialization_lease_refusal_result(error: BaseException) -> RepairResult: - """Translate active-generation lease refusal into the repair contract.""" - return _internal_derived_repair_result( +def raw_materialization_lease_refusal_result(error: BaseException) -> RawConvergenceResult: + """Translate active-generation lease refusal into the typed pass result.""" + return _raw_convergence_result( "raw_materialization", repaired_count=0, success=False, @@ -5695,662 +5092,11 @@ def raw_materialization_lease_refusal_result(error: BaseException) -> RepairResu ) -def _archive_debt_status( - target_name: str, - *, - issue_count: int, - detail: str, - skipped: bool = False, -) -> ArchiveDebtStatus: - spec = _maintenance_target_spec(target_name) - return ArchiveDebtStatus( - name=spec.name, - category=spec.category, - destructive=spec.destructive, - issue_count=issue_count, - detail=detail, - maintenance_target=spec.name, - skipped=skipped, - ) - - -def collect_archive_debt_statuses_sync( - conn: sqlite3.Connection, - *, - db_path: Path | str | None = None, - derived_statuses: dict[str, DerivedModelStatus] | None = None, - include_expensive: bool = True, - probe_only: bool = False, - target_names: tuple[str, ...] = (), - configured_root: Path | None = None, -) -> dict[str, ArchiveDebtStatus]: - from polylogue.storage.derived.derived_status import collect_derived_model_statuses_sync - - selected = set(target_names) if target_names else set(_MAINTENANCE_TARGET_CATALOG.names()) - needs_session_insights = "session_insights" in selected - statuses = ( - derived_statuses or collect_derived_model_statuses_sync(conn, verify_full=include_expensive) - if needs_session_insights - else {} - ) - - skip_large_message_scans = ( - probe_only - and not include_expensive - and _table_has_more_than(conn, "messages", _PROBE_ONLY_EXACT_MESSAGE_ROW_LIMIT) - ) - debt_statuses: dict[str, ArchiveDebtStatus] = {} - - if "empty_sessions" in selected: - empty_sessions = 0 if skip_large_message_scans else count_empty_sessions_sync(conn) - debt_statuses["empty_sessions"] = _archive_debt_status( - "empty_sessions", - issue_count=empty_sessions, - detail=( - "Skipped exact empty-session scan in probe mode; use --deep for exact count" - if skip_large_message_scans - else "No empty sessions" - if empty_sessions == 0 - else f"{empty_sessions:,} empty sessions" - ), - skipped=skip_large_message_scans, - ) - if "session_insights" in selected: - session_insights = session_insight_repair_count(statuses) - debt_statuses["session_insights"] = _archive_debt_status( - "session_insights", - issue_count=session_insights, - detail="Session insight read models ready" - if session_insights == 0 - else f"{session_insights:,} pending/stale/orphaned session-insight rows", - ) - if include_expensive and "superseded_raw_snapshots" in selected: - superseded_raw_snapshots = count_superseded_raw_snapshots_sync(conn) - debt_statuses["superseded_raw_snapshots"] = _archive_debt_status( - "superseded_raw_snapshots", - issue_count=superseded_raw_snapshots, - detail=( - "No superseded live raw snapshots" - if superseded_raw_snapshots == 0 - else f"{superseded_raw_snapshots:,} superseded live raw snapshots" - ), - ) - return debt_statuses - - -def preview_counts_from_archive_debt( - statuses: dict[str, ArchiveDebtStatus], -) -> dict[str, int]: - preview_targets = set(_MAINTENANCE_TARGET_CATALOG.preview_target_names()) - return { - status.maintenance_target: status.issue_count - for status in statuses.values() - if status.issue_count > 0 or status.maintenance_target in preview_targets - } - - -# --------------------------------------------------------------------------- -# Cleanup repairs (empty sessions, blobs, and raw snapshots) -# --------------------------------------------------------------------------- - - -def _sibling_source_db_path(conn: sqlite3.Connection) -> Path | None: - """Return the ``source.db`` sibling of the index-tier file backing *conn*. - - Both tiers always live side by side under the same archive root - (``storage/archive_identity.py``). Reading ``PRAGMA database_list`` - instead of threading a path parameter keeps this usable both from - ``repair_empty_sessions`` (which opens its own index connection) and from - ``count_empty_sessions_sync`` (called with a caller-supplied, possibly - read-only, connection in ``maintenance/preview.py``). - """ - for _seq, name, file in conn.execute("PRAGMA database_list"): - if name == "main" and file: - return Path(file).parent / "source.db" - return None - - -def _empty_session_candidate_ids(conn: sqlite3.Connection) -> list[tuple[str, str | None]]: - """Return ``(session_id, raw_id)`` for every session with no real content: - zero messages, or every message present carries zero words. - - The second branch (``s.word_count = 0`` with ``message_count`` possibly - nonzero) is what catches ``claude-code-session:conversation_relationships`` - (polylogue-21qj): 96,748 message rows, none of them carrying any block/word - content, from a sinex graph-edge index misclassified as a session's turn - stream before #3428. ``sessions.word_count`` is a materialized aggregate - over every message the session owns, so this stays a single indexed - comparison rather than a per-message join. - """ - return [ - (row["session_id"], row["raw_id"]) - for row in conn.execute( - """ - SELECT s.session_id AS session_id, s.raw_id AS raw_id - FROM sessions s - WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.session_id) - OR s.word_count = 0 - """ - ).fetchall() - ] - - -def _blob_store_for_connection(conn: sqlite3.Connection) -> BlobStore | None: - """The blob store belonging to the archive this connection is repairing. - - Inspection otherwise resolves blobs through the AMBIENT configured archive, - so repairing any other archive reads every blob from the wrong place. That - is not a hypothetical: it silently turns "these bytes are unreadable" into - the classifier's only observation, and the caller-supplied-archive contract - that maintenance is built on is exactly the case it breaks. - - Derived from the attached `source` database's own path, so it follows the - connection rather than process configuration. Returns None when that cannot - be determined, leaving the previous default in place. - """ - try: - rows = conn.execute("PRAGMA database_list").fetchall() - except sqlite3.Error: - return None - for row in rows: - name = row[1] if not isinstance(row, sqlite3.Row) else row["name"] - path = row[2] if not isinstance(row, sqlite3.Row) else row["file"] - if name == "source" and path: - return BlobStore(Path(path).parent / "blob") - return None - - -def _raw_artifact_positively_fails_classification(conn: sqlite3.Connection, raw_id: str | None) -> bool: - """Return ``True`` only when *raw_id* resolves to source bytes that the - CURRENT ``classify_artifact``/``inspect_raw_artifact`` pipeline positively - refuses to admit as a session (``parse_as_session is False``). - - Absence of positive evidence always means "retain": a missing/empty - ``raw_id``, a ``raw_sessions`` row that no longer exists (e.g. GC'd), or - an inspection failure all return ``False`` here rather than being treated - as debris by default. This is deliberately conservative in the opposite - direction from both predicates tried and refuted on polylogue-ne6k - (blanket "no messages", and "``raw_id IS NULL``"): a row can only be - deleted by *positive* evidence that the artifact behind it is not a - session, mirroring the ``looks_like_code`` fix in - ``sources/parsers/claude/code_detection.py`` (polylogue-9ykn/gvgi) that - requires a genuine record-envelope marker rather than a weak location- or - absence-based guess. - """ - if not raw_id: - return False - row = conn.execute("SELECT * FROM source.raw_sessions WHERE raw_id = ?", (raw_id,)).fetchone() - if row is None: - return False - - from polylogue.storage.artifacts.inspection import inspect_raw_artifact - from polylogue.storage.sqlite.queries.mappers import _row_to_raw_session - - try: - record = _row_to_raw_session(row) - observation = inspect_raw_artifact(record, blob_store=_blob_store_for_connection(conn)) - except Exception: - # Cannot classify -> no positive evidence -> retain. - return False - if observation.decode_error: - # A decode failure is *not* raised, it is reported on the observation - # with ``parse_as_session=False`` -- which the return below would read - # as positive evidence that this is not a session and delete the row. - # Unreadable bytes are the absence of evidence, and this function's - # contract (and the `except` above) is to retain in that case. The - # difference is destructive: inspection resolves the blob through the - # configured archive, so repairing any archive that is not the - # configured one fails to read every blob and would otherwise delete - # every message-less session it examined. - return False - return not observation.parse_as_session - - -def _empty_session_debris_session_ids( - conn: sqlite3.Connection, - session_ids: tuple[str, ...] | None = None, -) -> list[str]: - """Return ``session_id``s for message-less sessions whose raw artifact - positively fails the current record-shape classifier. - - Shared by ``count_empty_sessions_sync`` and ``repair_empty_sessions`` so - the reported debt and the deleted rows can never diverge (polylogue-ne6k). - - Both ``_empty_session_candidate_ids`` and - ``_raw_artifact_positively_fails_classification`` access rows by column - name, so this sets ``row_factory = sqlite3.Row`` defensively on entry - (restoring the caller's original factory on exit) rather than assuming - every caller-supplied connection already has it -- ``count_empty_sessions_sync``'s - own docstring documents that it is "called with a caller-supplied, - possibly read-only, connection" (polylogue-9rdky: the maintenance - planner's preview/execute path opens a plain tuple-row connection via - ``open_readonly_connection``, which crashed both helpers with - ``TypeError: tuple indices must be integers or slices, not str``). - """ - original_row_factory = conn.row_factory - conn.row_factory = sqlite3.Row - try: - candidates = _empty_session_candidate_ids(conn) - if session_ids is not None: - requested = set(session_ids) - candidates = [item for item in candidates if item[0] in requested] - if not candidates: - return [] - source_db = _sibling_source_db_path(conn) - if source_db is None or not source_db.exists(): - # No source tier reachable -> no way to obtain positive evidence for - # any candidate -> retain all of them. - return [] - conn.execute("ATTACH DATABASE ? AS source", (str(source_db),)) - try: - return [ - session_id - for session_id, raw_id in candidates - if _raw_artifact_positively_fails_classification(conn, raw_id) - ] - finally: - conn.execute("DETACH DATABASE source") - finally: - conn.row_factory = original_row_factory - - -def repair_empty_sessions( - config: Config, dry_run: bool = False, *, session_ids: tuple[str, ...] | None = None -) -> RepairResult: - """Delete message-less sessions whose raw artifact positively fails the - current record-shape classifier. - - See ``_raw_artifact_positively_fails_classification`` and - ``count_empty_sessions_sync`` for why "no messages" alone, and - "``raw_id IS NULL``", are both insufficient predicates -- both were tried - and refuted on polylogue-ne6k. A session with no messages is retained - unless its raw artifact, re-run through the live classification pipeline, - is itself refused as a session. - """ - del config - try: - with _open_archive_index_connection() as conn: - candidate_ids = _empty_session_debris_session_ids(conn, session_ids=session_ids) - if dry_run: - return _repair_result( - "empty_sessions", - repaired_count=len(candidate_ids), - success=True, - detail=( - f"Would: {len(candidate_ids)} rows affected" if candidate_ids else "Would: No issues found" - ), - ) - if not candidate_ids: - return _repair_result( - "empty_sessions", - repaired_count=0, - success=True, - detail="No repairs needed", - ) - conn.executemany( - "DELETE FROM sessions WHERE session_id = ?", - [(session_id,) for session_id in candidate_ids], - ) - conn.commit() - return _repair_result( - "empty_sessions", - repaired_count=len(candidate_ids), - success=True, - detail=f"Repaired {len(candidate_ids)} rows", - ) - except Exception as exc: - return _repair_result( - "empty_sessions", - repaired_count=0, - success=False, - detail=f"Repair failed: {exc}", - ) - - -def preview_empty_sessions(*, count: int) -> RepairResult: - return _repair_result( - "empty_sessions", - repaired_count=count, - success=True, - detail=f"Would: {count} rows affected" if count else "Would: No issues found", - ) - - -def count_superseded_raw_snapshots_sync(conn: sqlite3.Connection) -> int: - from polylogue.storage.raw_retention import superseded_raw_snapshot_candidates - - return len(superseded_raw_snapshot_candidates(conn, limit=10_000)) - - -def repair_superseded_raw_snapshots(config: Config, dry_run: bool = False) -> RepairResult: - """Delete redundant raw snapshots while promotion cannot change the protected set.""" - - if dry_run: - return _repair_superseded_raw_snapshots(config, dry_run=True) - - from polylogue.storage.index_generation import ActiveWriterLease, RebuildLeaseUnavailableError - - lease = ActiveWriterLease(_raw_materialization_archive_root(config)) - try: - lease.acquire() - except RebuildLeaseUnavailableError as exc: - return _repair_result( - "superseded_raw_snapshots", - repaired_count=0, - success=False, - detail=f"Skipped destructive raw cleanup: {exc}", - ) - try: - return _repair_superseded_raw_snapshots(config, dry_run=False) - finally: - lease.close() - - -def _repair_superseded_raw_snapshots(config: Config, dry_run: bool = False) -> RepairResult: - from polylogue.storage.raw_retention import ( - RawRetentionSafetyError, - active_raw_retention_authority, - cleanup_superseded_raw_snapshots, - ) - from polylogue.storage.sqlite.connection_profile import open_connection, open_readonly_connection - - archive_root = _raw_materialization_archive_root(config) - repair_db_path = archive_root / "source.db" - if repair_db_path.exists(): - index_db_path = _raw_materialization_index_path(config, archive_root) - if not index_db_path.is_file(): - return _repair_result( - "superseded_raw_snapshots", - repaired_count=0, - success=False, - detail=f"Skipped destructive raw cleanup: index tier is unavailable: {index_db_path}", - ) - try: - index_conn = open_readonly_connection(index_db_path) - except (OSError, sqlite3.Error) as exc: - return _repair_result( - "superseded_raw_snapshots", - repaired_count=0, - success=False, - detail=f"Skipped destructive raw cleanup: index tier raw authority is unreadable: {exc}", - ) - with ( - closing(open_connection(repair_db_path)) as conn, - closing(index_conn), - conn, - ): - conn.row_factory = sqlite3.Row - try: - retention_authority = active_raw_retention_authority( - conn, - index_db_path=index_db_path, - ) - except RawRetentionSafetyError as exc: - return _repair_result( - "superseded_raw_snapshots", - repaired_count=0, - success=False, - detail=f"Skipped destructive raw cleanup: {exc}", - ) - result = cleanup_superseded_raw_snapshots( - conn, - dry_run=dry_run, - limit=10_000, - protected_raw_ids=retention_authority.protected_raw_ids, - eligible_raw_ids=retention_authority.eligible_raw_ids, - index_conn=index_conn, - ) - else: - with closing(open_connection(config.db_path)) as conn, conn: - try: - retention_authority = active_raw_retention_authority( - conn, - index_db_path=config.db_path, - ) - except RawRetentionSafetyError as exc: - return _repair_result( - "superseded_raw_snapshots", - repaired_count=0, - success=False, - detail=f"Skipped destructive raw cleanup: {exc}", - ) - result = cleanup_superseded_raw_snapshots( - conn, - dry_run=dry_run, - limit=10_000, - protected_raw_ids=retention_authority.protected_raw_ids, - eligible_raw_ids=retention_authority.eligible_raw_ids, - index_conn=conn, - ) - if dry_run: - skipped_detail = ( - f"; skipped {result.skipped_referenced_count:,} active revision raw rows" - if result.skipped_referenced_count - else "" - ) - return _repair_result( - "superseded_raw_snapshots", - repaired_count=result.candidate_count, - success=True, - detail=( - f"Would: delete {result.candidate_count:,} superseded raw snapshots " - f"({result.deleted_raw_bytes:,} referenced bytes)" - f"{skipped_detail}" - ), - ) - skipped_detail = ( - f"; skipped {result.skipped_referenced_count:,} active revision raw rows" - if result.skipped_referenced_count - else "" - ) - orphaned_plan_detail = ( - f"; pruned {result.deleted_orphaned_authority_plan_count:,} orphaned raw-authority plan(s)" - if result.deleted_orphaned_authority_plan_count - else "" - ) - return _repair_result( - "superseded_raw_snapshots", - repaired_count=result.deleted_raw_count, - success=not result.errors, - detail=( - f"Deleted {result.deleted_raw_count:,} raw rows and {result.deleted_blob_count:,} blob files " - f"({result.deleted_blob_bytes:,} bytes)" - f"{skipped_detail}{orphaned_plan_detail}" - + (f"; errors: {'; '.join(result.errors[:3])}" if result.errors else "") - ), - ) - - -def preview_superseded_raw_snapshots(*, count: int) -> RepairResult: - return _repair_result( - "superseded_raw_snapshots", - repaired_count=count, - success=True, - detail=( - f"Would: delete {count} superseded live raw snapshots" - if count - else "Would: No superseded live raw snapshots found" - ), - ) - - # --------------------------------------------------------------------------- # Derived repairs (session insights, actions, FTS, WAL) # --------------------------------------------------------------------------- -def repair_session_insights( - config: Config, - dry_run: bool = False, - *, - progress_callback: ProgressCallback | None = None, - progress_total: int | None = None, - session_ids: tuple[str, ...] | None = None, - archive_root_override: Path | None = None, - owned_inactive_generation: tuple[str, str] | None = None, - resolve_convergence_debt: bool = True, -) -> RepairResult: - """Repair / rebuild session insights. - - When ``session_ids`` is given, the rebuild is narrowed to that - set instead of touching the full archive — used by the maintenance - planner to honor :class:`MaintenanceScopeFilter.session_ids`. - - KEEP-WITH-REASON (polylogue-ygfwa): this mutate path is *not* - fully redundant with the daemon's automatic convergence mechanisms - (the per-ingest ``make_insights_stage`` ``ConvergenceStage`` and the - periodic ``convergence_debt`` retry loop in ``daemon/cli.py``). Both - automatic mechanisms only ever call ``rebuild_session_insights_sync`` - (per-session profile/work_events/phases). Neither one ever calls - ``refresh_session_insight_aggregates_sync`` — the archive-wide, - non-per-session-scoped refresh of thread materialization - (``threads``/``thread_sessions``), tag rollups - (``session_tag_rollups``), and provider-day aggregates. This - function is the *only* caller of - ``refresh_session_insight_aggregates_sync`` in the codebase (verified - by grep across ``daemon/`` and the rest of the tree, 2026-08-02): it - runs that refresh whenever ``_session_insight_aggregate_debt_count`` - (``missing_thread_materialization_count``, ``stale_thread_count``, - ``orphan_thread_count``, ``stale_tag_rollup_count``, - ``stale_day_summary_count``) is nonzero. So a bump to - ``SESSION_INSIGHT_MATERIALIZER_VERSION`` (or any other event that - stales thread/tag-rollup aggregates archive-wide) leaves those - aggregates stale forever unless something calls this manual repair - path — the daemon has no automatic route to clear that debt. This - function is also reused directly (not via the doctor CLI) by - ``maintenance/rebuild_index.py``'s terminal stage to materialize - insights for a freshly built *inactive* generation before promotion, - a scenario the daemon (which only ever touches the live/active - generation) cannot reach at all. Do not remove the mutate path - without first giving thread/tag-rollup/day-summary aggregate - staleness its own automatic convergence mechanism. - - ``resolve_convergence_debt=False`` is reserved for an owned inactive - generation. Its ``ops.db`` is a read-through link to live disposable - state, so candidate materialization may prove derived readiness without - clearing the active daemon's debt ledger. - """ - from polylogue.paths import archive_root as _resolve_archive_root - from polylogue.storage.insights.session.rebuild import ( - rebuild_archive_session_insights, - refresh_session_insight_aggregates_sync, - ) - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - try: - archive_root = archive_root_override or resolve_active_index_path(_resolve_archive_root()).parent - archive_context = ( - ArchiveStore.open_owned_inactive_generation( - archive_root, - generation_id=owned_inactive_generation[0], - owner_id=owned_inactive_generation[1], - ) - if owned_inactive_generation is not None - else ArchiveStore.open_existing(archive_root, read_only=False) - ) - with archive_context as archive: - status = archive.session_insight_status() - assessment = assess_session_insight_repairs(status) - aggregate_debt = _session_insight_aggregate_debt_count(status) - targeted_session_ids = ( - None - if session_ids is not None or (assessment.row_debt == 0 and aggregate_debt == 0) - else _targeted_session_insight_rebuild_ids(getattr(archive, "_conn", None), status) - ) - - if dry_run: - if session_ids is not None: - pending = min(assessment.row_debt, len(session_ids)) - detail = ( - "Would: session insights already ready" - if pending == 0 - else f"Would: rebuild session insights for {pending:,} scoped session(s)" - ) - elif targeted_session_ids is not None: - pending = len(targeted_session_ids) - detail = ( - "Would: session insights already ready" - if pending == 0 - else ( - "Would: rebuild session insights for " - f"{len(targeted_session_ids):,} candidate session(s), including any affected " - "thread-materialization marker(s)" - f" to repair {assessment.row_debt:,} total debt row(s)" - ) - ) - elif assessment.row_debt == 0: - pending = 0 - detail = "Would: session insights already ready" - else: - pending = status.total_sessions - detail = ( - "Would: rebuild archive-wide session insights " - f"for {pending:,} session(s) to repair {assessment.row_debt:,} debt row(s)" - ) - return _repair_result( - "session_insights", - repaired_count=pending, - success=True, - detail=detail, - ) - - if session_ids is None and assessment.row_debt == 0 and aggregate_debt == 0: - return _repair_result( - "session_insights", - repaired_count=0, - success=True, - detail="Session insights already ready", - ) - - rebuild_session_ids = session_ids if session_ids is not None else targeted_session_ids - rebuilt = rebuild_archive_session_insights( - archive, - session_ids=rebuild_session_ids, - progress_callback=progress_callback, - ) - rebuilt_count = rebuilt.total() - refreshed = archive.session_insight_status() - if session_ids is None and _session_insight_aggregate_debt_count(refreshed) > 0: - aggregate_counts = refresh_session_insight_aggregates_sync( - archive._conn, - progress_callback=progress_callback, - ) - rebuilt_count += aggregate_counts.total() - refreshed = archive.session_insight_status() - # A narrowed rebuild only attests its own slice; do not - # demand global readiness for a scope-filtered call. - success = True if session_ids is not None else assess_session_insight_repairs(refreshed).row_debt == 0 - if success and resolve_convergence_debt: - _resolve_session_insight_convergence_debt( - ops_db=config.archive_root / "ops.db", - session_ids=session_ids, - ) - return _repair_result( - "session_insights", - repaired_count=rebuilt_count, - success=success, - detail="Session insights ready" if success else "Session insights still incomplete", - ) - except Exception as exc: - return _repair_result( - "session_insights", - repaired_count=0, - success=False, - detail=f"Failed to repair session insights: {exc}", - ) - - -def preview_session_insights(*, count: int) -> RepairResult: - return _repair_result( - "session_insights", - repaired_count=count, - success=True, - detail="Would: session insights already ready" - if count == 0 - else f"Would: rebuild session-insight rows/fts for {count:,} pending items", - ) - - def _resolve_raw_authority_commit_batch_size(commit_batch_size: int | None) -> int | None: """Resolve the census-phase commit batch size (polylogue-amg1). @@ -6373,7 +5119,7 @@ def _resolve_raw_authority_commit_batch_size(commit_batch_size: int | None) -> i return configured if configured > 0 else None -def repair_raw_materialization( +def converge_raw_materialization( config: Config, dry_run: bool = False, *, @@ -6388,11 +5134,11 @@ def repair_raw_materialization( progress_callback: ProgressCallback | None = None, prefetch_cache: RawParsePrefetchCache | None = None, max_pass_seconds: float | None = None, -) -> RepairResult: +) -> RawConvergenceResult: """Converge one raw-materialization pass under active-generation ownership.""" - def run() -> RepairResult: - return _repair_raw_materialization( + def run() -> RawConvergenceResult: + return _converge_raw_materialization( config, dry_run=dry_run, raw_artifact_id=raw_artifact_id, @@ -6425,7 +5171,7 @@ def run() -> RepairResult: lease.close() -def _repair_raw_materialization( +def _converge_raw_materialization( config: Config, dry_run: bool = False, *, @@ -6440,7 +5186,7 @@ def _repair_raw_materialization( progress_callback: ProgressCallback | None = None, prefetch_cache: RawParsePrefetchCache | None = None, max_pass_seconds: float | None = None, -) -> RepairResult: +) -> RawConvergenceResult: """Converge retained raws through typed per-session revision authority. ``ingest_workers`` bounds how many processes parse independent raws @@ -6535,7 +5281,7 @@ def _pass_deadline_exceeded() -> bool: blocker_count = unresolved_raw_replay_blockers(archive_root) if blocker_count: - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, success=False, @@ -6720,7 +5466,7 @@ def _pass_deadline_exceeded() -> bool: f"; {len(census_resource_blocked_raw_ids):,} raw(s) belong to authority components whose " f"aggregate payload exceeds {_format_bytes(max_payload_bytes)}" ) - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, success=False, @@ -6865,7 +5611,7 @@ def _pass_deadline_exceeded() -> bool: f"; {len(no_progress_plan_ids):,} unchanged plan(s) remain terminal after making zero " "typed progress and require investigation before retry" ) - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, # A no-progress plan is durable, investigation-requiring debt -- @@ -6924,7 +5670,7 @@ def _pass_deadline_exceeded() -> bool: ) if missing_blobs: detail += f"; {_raw_materialization_missing_blob_detail(candidates, final=True)}" - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, success=( @@ -6974,7 +5720,7 @@ def _pass_deadline_exceeded() -> bool: ) if oversized_stream_safe_raw_ids: detail += f"; {len(oversized_stream_safe_raw_ids):,} oversized stream-record raw rows are stream-capable" - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, # polylogue-f57q: a dry-run PREVIEW that reaches this branch has @@ -7048,7 +5794,7 @@ def _pass_deadline_exceeded() -> bool: metrics["raw_materialization_plan_carried_forward_count"] = float(carried_forward_count) metrics["raw_materialization_plan_outcome_count"] = float(plan_count) metrics["raw_materialization_plan_conservation_error_count"] = float(conservation_error_count) - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=0, success=False, @@ -7392,7 +6138,7 @@ def _pass_deadline_exceeded() -> bool: detail += f"; {_raw_materialization_missing_blob_detail(remaining, final=True)}" if progress_callback is not None: progress_callback(replay.replayed_logical_sources, detail) - return _internal_derived_repair_result( + return _raw_convergence_result( "raw_materialization", repaired_count=replay.replayed_logical_sources, success=success, @@ -7403,178 +6149,6 @@ def _pass_deadline_exceeded() -> bool: ) -PREVIEW_HANDLERS: dict[str, Callable[..., RepairResult]] = { - "session_insights": preview_session_insights, - "empty_sessions": preview_empty_sessions, - "superseded_raw_snapshots": preview_superseded_raw_snapshots, -} - - -REPAIR_HANDLERS: dict[str, Callable[..., RepairResult]] = { - "session_insights": repair_session_insights, - "empty_sessions": repair_empty_sessions, - "superseded_raw_snapshots": repair_superseded_raw_snapshots, -} - - # --------------------------------------------------------------------------- # Orchestration (run_safe_repairs, run_archive_cleanup, run_selected_maintenance) # --------------------------------------------------------------------------- - - -def run_safe_repairs( - config: Config, - dry_run: bool = False, - *, - preview_counts: dict[str, int] | None = None, - targets: tuple[str, ...] = (), - session_insight_progress_callback: ProgressCallback | None = None, - session_insight_progress_total: int | None = None, - session_ids: tuple[str, ...] | None = None, -) -> list[RepairResult]: - preview_counts = preview_counts or {} - selected = set(targets) if targets else set(SAFE_REPAIR_TARGETS) - results: list[RepairResult] = [] - for target_name in SAFE_REPAIR_TARGETS: - if target_name not in selected: - continue - if dry_run and target_name in preview_counts: - preview = PREVIEW_HANDLERS.get(target_name) - if preview is not None: - results.append(preview(count=preview_counts[target_name])) - continue - repair = REPAIR_HANDLERS[target_name] - if target_name == "session_insights": - results.append( - repair( - config, - dry_run=dry_run, - progress_callback=session_insight_progress_callback, - progress_total=session_insight_progress_total, - session_ids=session_ids, - ) - ) - continue - results.append(repair(config, dry_run=dry_run)) - return results - - -def run_archive_cleanup( - config: Config, - dry_run: bool = False, - *, - preview_counts: dict[str, int] | None = None, - targets: tuple[str, ...] = (), - session_ids: tuple[str, ...] | None = None, -) -> list[RepairResult]: - preview_counts = preview_counts or {} - selected = set(targets) if targets else set(CLEANUP_TARGETS) - results: list[RepairResult] = [] - for target_name in CLEANUP_TARGETS: - if target_name not in selected: - continue - if dry_run and session_ids is None and target_name in preview_counts: - results.append(PREVIEW_HANDLERS[target_name](count=preview_counts[target_name])) - continue - repair = REPAIR_HANDLERS[target_name] - if target_name == "empty_sessions" and session_ids is not None: - results.append(repair(config, dry_run=dry_run, session_ids=session_ids)) - else: - results.append(repair(config, dry_run=dry_run)) - return results - - -def run_selected_maintenance( - config: Config, - *, - repair: bool, - cleanup: bool, - dry_run: bool = False, - preview_counts: dict[str, int] | None = None, - targets: tuple[str, ...] = (), - session_insight_progress_callback: ProgressCallback | None = None, - session_insight_progress_total: int | None = None, - scope_filter: MaintenanceScopeFilter | None = None, -) -> list[RepairResult]: - blockers = offline_maintenance_blockers( - config, - repair=repair, - cleanup=cleanup, - dry_run=dry_run, - targets=targets, - ) - if blockers: - return blockers - results: list[RepairResult] = [] - repair_targets = tuple(name for name in targets if name in SAFE_REPAIR_TARGETS) or ( - SAFE_REPAIR_TARGETS if repair and not targets else () - ) - cleanup_targets = tuple(name for name in targets if name in CLEANUP_TARGETS) or ( - CLEANUP_TARGETS if cleanup and not targets else () - ) - effective_filter = scope_filter or MaintenanceScopeFilter() - selected_names = (*repair_targets, *cleanup_targets) - unsupported_targets: set[str] = set() - for target_name in selected_names: - unsupported = unsupported_scope_dimensions(effective_filter, target=target_name) - if unsupported: - unsupported_targets.add(target_name) - results.append( - _repair_result( - target_name, - repaired_count=0, - success=False, - detail=(f"Unsupported scope dimensions for target {target_name!r}: {', '.join(unsupported)}"), - ) - ) - repair_targets = tuple(name for name in repair_targets if name not in unsupported_targets) - cleanup_targets = tuple(name for name in cleanup_targets if name not in unsupported_targets) - if repair and repair_targets: - results.extend( - run_safe_repairs( - config, - dry_run=dry_run, - preview_counts=preview_counts, - targets=repair_targets, - session_insight_progress_callback=session_insight_progress_callback, - session_insight_progress_total=session_insight_progress_total, - session_ids=effective_filter.session_ids, - ) - ) - if cleanup and cleanup_targets: - results.extend( - run_archive_cleanup( - config, - dry_run=dry_run, - preview_counts=preview_counts, - targets=cleanup_targets, - session_ids=effective_filter.session_ids, - ) - ) - return results - - -__all__ = [ - "ArchiveDebtStatus", - "PREVIEW_HANDLERS", - "REPAIR_HANDLERS", - "RepairResult", - "collect_archive_debt_statuses_sync", - "count_empty_sessions_sync", - "count_superseded_raw_snapshots_sync", - "preview_counts_from_archive_debt", - "preview_empty_sessions", - "preview_superseded_raw_snapshots", - "preview_session_insights", - "raw_materialization_lease_refusal_result", - "raw_materialization_replay_backlog", - "raw_materialization_scale_profile", - "repair_empty_sessions", - "repair_raw_materialization", - "repair_superseded_raw_snapshots", - "repair_session_insights", - "run_archive_cleanup", - "run_safe_repairs", - "run_selected_maintenance", - "session_insight_repair_count", -] diff --git a/polylogue/storage/raw_reconciler.py b/polylogue/storage/raw_reconciler.py index 4b06ed72c8..6b81011322 100644 --- a/polylogue/storage/raw_reconciler.py +++ b/polylogue/storage/raw_reconciler.py @@ -1,7 +1,7 @@ """Proof-driven census for every accepted raw-authority frontier. This module owns the provider-neutral state machine. Historical incident -actuators remain implementation strategies in :mod:`polylogue.storage.repair`; +actuators remain implementation strategies in :mod:`polylogue.storage.raw_convergence`; they do not get to define separate public notions of plan identity, evidence, or readiness. """ @@ -41,7 +41,7 @@ logger = get_logger(__name__) if TYPE_CHECKING: - from polylogue.storage.repair import ( + from polylogue.storage.raw_convergence import ( BrowserCaptureOriginRepairItem, DuplicateRawIdentityRepairItem, QuarantinedAcceptedRawRepairItem, @@ -318,7 +318,7 @@ def _archive_root(config: Config) -> Path: """Return the archive file-set root housing the currently active database. Deliberately follows ``config.db_path`` (not ``config.archive_root``), - matching :func:`polylogue.storage.repair._raw_materialization_archive_root`: + matching :func:`polylogue.storage.raw_convergence._raw_materialization_archive_root`: this reconciler inspects the database and blob store that are actually live right now, which ``config.db_path`` already resolves correctly (``.index-active-pointer``-aware, or an explicit override) inside @@ -405,7 +405,7 @@ def _verified_blob_bytes(conn: sqlite3.Connection, blob_store: BlobStore, hash_h def _browser_strategy_witness(item: BrowserCaptureOriginRepairItem) -> JSONDocument: - from polylogue.storage.repair import _browser_origin_item_payload + from polylogue.storage.raw_convergence import _browser_origin_item_payload return json_document( { @@ -432,7 +432,7 @@ def _quarantine_strategy_witness(item: QuarantinedAcceptedRawRepairItem) -> JSON def _duplicate_strategy_witness(item: DuplicateRawIdentityRepairItem) -> JSONDocument: - from polylogue.storage.repair import _duplicate_raw_identity_proof_digest + from polylogue.storage.raw_convergence import _duplicate_raw_identity_proof_digest return json_document( { @@ -668,7 +668,7 @@ def _classify_frontier( ) duplicate_siblings = _duplicate_alias_siblings(conn, row) if duplicate_siblings and row.get("native_id") is not None: - from polylogue.storage.repair import _inspect_duplicate_raw_identity + from polylogue.storage.raw_convergence import _inspect_duplicate_raw_identity if len(duplicate_siblings) != 1: raise RuntimeError(f"duplicate alias classification is not injective for {raw_id}") @@ -787,7 +787,7 @@ def _strategy_overrides( index_db_path: Path, ) -> dict[str, _StrategyOverride]: """Ask legacy incident inspectors for proofs, never for plan identity.""" - from polylogue.storage.repair import ( + from polylogue.storage.raw_convergence import ( inspect_browser_canonical_authority_conflicts, inspect_browser_capture_origin_mismatches, inspect_quarantined_accepted_raws, @@ -1436,7 +1436,7 @@ def _apply_strategy( item: RawAuthorityFrontierItem, ) -> JSONDocument: from polylogue.storage.index_generation import RebuildLease - from polylogue.storage.repair import ( + from polylogue.storage.raw_convergence import ( _apply_browser_conflict_canonical_resolution, _apply_browser_origin_repair_item, _apply_duplicate_raw_identity_repair, diff --git a/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py b/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py index dbcb4204e6..01b93949bd 100644 --- a/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py +++ b/polylogue/storage/sqlite/archive_tiers/ingest_precedence.py @@ -208,7 +208,7 @@ def revision_authority_refuses_write( ).fetchone() if has_revision_heads is not None: # Historical drift or an interrupted repair can leave more than one - # raw_revision_heads row for a session (storage/repair.py's + # raw_revision_heads row for a session (storage/raw_convergence.py's # ``parallel_session_heads`` shape). A bare ``LIMIT 1`` examined an # arbitrary one of those rows, so an incoming raw matching whichever # row happened to be selected was allowed through even when a diff --git a/polylogue/storage/sqlite/archive_tiers/source_write.py b/polylogue/storage/sqlite/archive_tiers/source_write.py index ab3e598ab2..dc754de717 100644 --- a/polylogue/storage/sqlite/archive_tiers/source_write.py +++ b/polylogue/storage/sqlite/archive_tiers/source_write.py @@ -551,7 +551,7 @@ def insert_reconstructed_raw_row( ``admit_raw_observation`` decides *what an observation means*: it takes freshly read bytes plus the accepted head and resolves which revision envelope those bytes earn. Every input to that decision is missing here. - A copy-forward repair (``storage/repair.py``'s browser-origin + A copy-forward repair (``storage/raw_convergence.py``'s browser-origin reconstruction) is not observing a source at all -- the source it would observe is gone. It is rewriting evidence the archive already holds and has already adjudicated, under a corrected identity, from a repair plan diff --git a/polylogue/storage/sqlite/lifecycle.py b/polylogue/storage/sqlite/lifecycle.py index f28e66128d..c0b558c883 100644 --- a/polylogue/storage/sqlite/lifecycle.py +++ b/polylogue/storage/sqlite/lifecycle.py @@ -73,14 +73,9 @@ def same_version_schema_variants(version: int) -> tuple[SameVersionSchemaVariant class TargetedReprocessScope: """A bounded reprocess scope over already-persisted sessions, as data. - Mirrors the ``origin``/``session_ids`` dimensions of - ``polylogue.maintenance.scope.MaintenanceScopeFilter`` -- the vocabulary - every other maintenance backfill already scopes by -- without importing - that module here: ``lifecycle.py`` is deliberately free of any - non-stdlib import so the schema-versioning policy lint, the fast-forward - executor, and unit tests can all evaluate it from the thinnest possible - surface. A caller that already imports ``polylogue.maintenance`` can - losslessly round-trip an instance into a ``MaintenanceScopeFilter``. + ``lifecycle.py`` is deliberately free of any non-stdlib import so the + schema-versioning policy lint, the fast-forward executor, and unit tests + can all evaluate it from the thinnest possible surface. At least one dimension must be set -- an empty scope would silently mean "every session", which is exactly the full-corpus cost this delta class From bdb44584498e96902fb7e8b8e46574d421d4c041 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 04:21:14 +0200 Subject: [PATCH 03/11] chore: retire the storage/index FTS shim and rewrite maintenance docs for the sole route storage/index.py had no production caller; tests reach the FTS lifecycle owner through tests/infra/fts.py. docs/maintenance.md, cost-model, security, daemon, configuration, onboarding, atlas/mcp and the design inventory describe daemon convergence as the only build path. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- docs/architecture-hotspots.md | 15 +- docs/atlas/mcp.md | 2 +- docs/cli-reference.md | 14 +- docs/code-navigation.md | 15 +- docs/configuration.md | 8 +- docs/cost-model.md | 33 +- docs/daemon-concurrency-profile.md | 7 +- docs/daemon.md | 2 +- .../convergence-simplification-inventory.md | 62 +- docs/internals.md | 2 +- docs/library-api.md | 2 +- docs/maintenance.md | 405 +----- docs/onboarding.md | 10 +- docs/openapi/search.yaml | 552 -------- docs/security.md | 3 +- polylogue/storage/index.py | 66 - tests/benchmarks/test_pipeline.py | 10 +- tests/infra/fts.py | 36 + tests/integration/test_workflows.py | 12 +- .../property/test_encoding_boundary_matrix.py | 10 +- tests/unit/cli/conftest.py | 6 +- tests/unit/cli/test_cli_output_schemas.py | 4 +- tests/unit/core/test_maintenance_targets.py | 58 - .../test_status_maintenance_failures.py | 242 ---- .../maintenance/test_envelope_contracts.py | 390 ------ .../unit/maintenance/test_failure_routing.py | 405 ------ tests/unit/maintenance/test_idempotency.py | 207 --- .../unit/maintenance/test_planner_contract.py | 445 ------- .../test_planner_filter_narrowing.py | 204 --- tests/unit/maintenance/test_preview.py | 435 ------ tests/unit/maintenance/test_registry.py | 337 ----- tests/unit/maintenance/test_resume.py | 1108 ---------------- tests/unit/maintenance/test_scope_filter.py | 340 ----- .../test_scope_filter_envelope_contract.py | 345 ----- .../test_scope_filter_roundtrip.py | 231 ---- tests/unit/maintenance/test_targets.py | 366 ------ .../test_empty_session_repair_provenance.py | 276 ---- .../test_incremental_rebuild_equivalence.py | 1169 ----------------- tests/unit/storage/test_index.py | 37 - ...test_repair.py => test_raw_convergence.py} | 0 ...test_reindex_derived_model_differential.py | 243 ---- ...est_session_profile_staleness_predicate.py | 193 --- 42 files changed, 123 insertions(+), 8184 deletions(-) delete mode 100644 polylogue/storage/index.py create mode 100644 tests/infra/fts.py delete mode 100644 tests/unit/core/test_maintenance_targets.py delete mode 100644 tests/unit/daemon/test_status_maintenance_failures.py delete mode 100644 tests/unit/maintenance/test_envelope_contracts.py delete mode 100644 tests/unit/maintenance/test_failure_routing.py delete mode 100644 tests/unit/maintenance/test_idempotency.py delete mode 100644 tests/unit/maintenance/test_planner_contract.py delete mode 100644 tests/unit/maintenance/test_planner_filter_narrowing.py delete mode 100644 tests/unit/maintenance/test_preview.py delete mode 100644 tests/unit/maintenance/test_registry.py delete mode 100644 tests/unit/maintenance/test_resume.py delete mode 100644 tests/unit/maintenance/test_scope_filter.py delete mode 100644 tests/unit/maintenance/test_scope_filter_envelope_contract.py delete mode 100644 tests/unit/maintenance/test_scope_filter_roundtrip.py delete mode 100644 tests/unit/maintenance/test_targets.py delete mode 100644 tests/unit/storage/test_empty_session_repair_provenance.py delete mode 100644 tests/unit/storage/test_incremental_rebuild_equivalence.py delete mode 100644 tests/unit/storage/test_index.py rename tests/unit/storage/{test_repair.py => test_raw_convergence.py} (100%) delete mode 100644 tests/unit/storage/test_reindex_derived_model_differential.py delete mode 100644 tests/unit/test_session_profile_staleness_predicate.py diff --git a/docs/architecture-hotspots.md b/docs/architecture-hotspots.md index b5ef46dd24..a7d1d8c162 100644 --- a/docs/architecture-hotspots.md +++ b/docs/architecture-hotspots.md @@ -25,7 +25,7 @@ won't hurt." | - | --- | --- | --- | --- | --- | | 1 | Storage read tier | `storage/sqlite/archive_tiers/archive.py` | 11,382 → **11,324** lines (raw-revision/membership governance extracted to `archive_tiers/revision_governance.py`, 2,841 lines, polylogue-1r9c) | `ArchiveStore` — every SELECT-shaped query surface (sessions, messages, blocks, insights reads, search) | `storage/` | | 2 | API facade | `api/archive.py` | 5,881 lines | `Polylogue.repository`/`.backend` verb surface consumed by CLI/MCP/daemon | `api/` (surface) | -| 3 | Storage repair | `storage/repair.py` | 5,558 lines | `repair_*`/`preview_*`/`run_safe_repairs`/`collect_archive_debt_statuses_sync` — every integrity-repair entrypoint the CLI `check`/`repair` commands and daemon convergence call | `storage/` (maintenance-adjacent, see note below) | +| 3 | Raw convergence | `storage/raw_convergence.py` | 5,558 lines at census time | `converge_raw_materialization` and the raw-authority frontier strategies the daemon's raw drain executes | `storage/` | | 4 | Daemon HTTP | `daemon/http.py` | 4,609 lines | the daemon's REST/web-shell route table | `daemon/` (surface) | | 5 | Storage write tier | `storage/sqlite/archive_tiers/write.py` | 4,595 → **4,210** lines (this bead's slice 1 landed) | `write_parsed_session_to_archive` + session/message/block/tag/work-event/phase writers | `storage/` | | 6 | CLI query dispatch | `cli/archive_query.py`, fn `_execute_archive_query_stdout` | 2,488 lines file / 632-line function (174-805) | the `find`/`read`/`analyze` query-mode stdout path | `cli/` (surface) | @@ -33,19 +33,6 @@ won't hurt." | 8a | ~~MCP read tools~~ **resolved** | ~~`mcp/server_tools.py`, fn `register_read_tools`~~ | 1,286 lines file / 490-line function → **0** (function and its file's other per-tool registrars deleted; `server_tools.py` is now a 19-line passthrough into `mcp/server_cutover.py`, polylogue-t46.8) | was every read-only MCP tool registration; replaced by the six-tool `query`/`read`/`get`/`explain`/`context`/`status` algebra | `mcp/` (surface) | | 8b | ~~MCP mutation tools~~ **resolved** | ~~`mcp/server_mutation_tools.py`, fn `register_mutation_tools`~~ | 497 lines → **0** (file deleted; logic re-hosted as `_dispatch_write` in `mcp/server_cutover.py`, polylogue-t46.8.3) | was every write MCP tool registration; replaced by the single capability-gated `write(operation=, ...)` transaction | `mcp/` (surface) | -Note on #3: `storage/repair.py`'s placement is itself a case study for the -polylogue-c9y placement doctrine — under the new rule-5 test ("integrity -repair — detecting and fixing rows that violate an invariant the write path -should have prevented") this is squarely `maintenance/` territory by -function, but it lives under `storage/` today because it also owns -low-level SQL the `maintenance/` package doesn't otherwise touch (receipt -files, WAL journal-mode manipulation, quarantine census staging). A future -slice should decide: either `maintenance/` absorbs the orchestration layer -and calls into `storage/` for the SQL primitives (matches the doctrine), or -the doctrine gets a documented exception for repair modules that are -SQL-heavy enough to need `storage/`'s proximity. Not decided in this pass — -flagged as an open question for whichever child bead executes #3's slice. - ## Call boundaries and ownership seams - **#1 (read tier) ← everything reads through it.** `#2` (API facade), the diff --git a/docs/atlas/mcp.md b/docs/atlas/mcp.md index ae0ce6352a..28e2f8b7c8 100644 --- a/docs/atlas/mcp.md +++ b/docs/atlas/mcp.md @@ -19,7 +19,7 @@ The live MCP surface is a twelve-tool operation algebra. Six read tools are alwa | `emit_decision` | `write` | Append a decision event with evidence references (`polylogue/mcp/declarations/registry.py:181-194`) | | `judge` | `judge` | Decide assertion candidates (`polylogue/mcp/declarations/registry.py:195-208`) | | `run` | `write` | Execute saved query or recipe refs (`polylogue/mcp/declarations/registry.py:209-222`) | -| `maintenance` | `maintenance` | Preview, execute, inspect, and rebuild (`polylogue/mcp/declarations/registry.py:223-238`) | +| `maintenance` | `maintenance` | Rebuild derived indexes and inspect or adjudicate operation recovery (`polylogue/mcp/declarations/registry.py:223-238`) | `write`, `judge`, and `maintenance` are independent booleans, not a role ladder. `run` shares the `write` gate (`polylogue/mcp/declarations/models.py:16-40`; `tests/unit/mcp/test_tool_declarations.py:26-41`). diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 97000be308..988dc6aec6 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -570,22 +570,12 @@ Commands: ```text Usage: polylogue ops doctor [OPTIONS] - Health check with optional maintenance and cleanup previews. + Read-only health check over the archive, runtime, daemon, blobs, and + schemas. Options: -f, --format [json] Output format -v, --verbose Show breakdown by origin - --repair Run safe derived-data maintenance repairs - --cleanup Run destructive archive cleanup for orphaned - or empty persisted data - --target [session_insights|empty_sessions|superseded_raw_snapshots] - Limit maintenance to named targets such as - session_insights, empty_sessions, or - superseded_raw_snapshots - --preview Preview maintenance without executing - (requires --repair or --cleanup) - --vacuum Reclaim unused space after maintenance - (requires --repair or --cleanup) --deep Run SQLite integrity and expensive orphan scans (slow on large databases) --runtime Run environment and runtime verification diff --git a/docs/code-navigation.md b/docs/code-navigation.md index 659b361260..2abf0f281a 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -9,7 +9,7 @@ landmarks in [Internals](internals.md). The governing rule is simple: > Put a change in the layer that owns its meaning, then adapt outward. Do not -> start from the CLI, daemon, or a repair command and work inward. +> start from the CLI, daemon, or a maintenance verb and work inward. ## Five-minute mental model @@ -39,8 +39,9 @@ CLI / API / MCP / HTTP / rendering surfaces `polylogued` owns normal writes. `source.db`, `user.db`, and source blob bytes are durable evidence. `index.db`, `embeddings.db`, insights, FTS, and most -status products are rebuildable. Maintenance code may verify or repair an -invariant, but it is never the normal home for new archive semantics. +status products are rebuildable and converge through the daemon. Maintenance +code verifies invariants or recovers durable evidence; it is never the normal +home for new archive semantics. ## Read the code in this order @@ -63,7 +64,7 @@ entire package tree: declared multi-surface operations. 8. [`polylogue/daemon/convergence.py`](../polylogue/daemon/convergence.py) and [`convergence_stages.py`](../polylogue/daemon/convergence_stages.py) — - bounded repair of rebuildable products after ingest. + bounded convergence of rebuildable products after ingest. 9. [`polylogue/surfaces/payloads.py`](../polylogue/surfaces/payloads.py) — provider-neutral response payloads shared by public surfaces. 10. [`docs/plans/layering.yaml`](plans/layering.yaml) — enforced import and @@ -144,8 +145,8 @@ enforced boundary authority. ### Verification worlds -- `maintenance/` — fail-closed verification and operator-supervised repair over - typed storage primitives; never the primary write path. +- `maintenance/` — fail-closed verification and guarded recovery over typed + storage primitives; never the primary write path. - `schemas/` — provider schema observation, inference, validation, and drift. - `scenarios/` — reusable scenario declarations and executable workload worlds. - `demo/` — deterministic private-data-free product demonstrations. @@ -172,7 +173,7 @@ registry or declaration, not the rendered output. - **Raw SQL outside `storage/`.** Add or call a storage accessor instead. - **Normal semantics in `maintenance/`.** Fix the write path; keep maintenance - for diagnosis, one-shot repair, and recovery. + for diagnosis and recovery. - **Inferring `Provider` from `Origin`.** The mapping is not injective. Preserve original acquisition evidence at wire boundaries. - **Surface-specific copies of domain policy.** Put the rule in `archive/`, diff --git a/docs/configuration.md b/docs/configuration.md index 883bdb8e63..5df3d78ee9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -344,7 +344,7 @@ A few keys not shown in the full example above, with their TOML path: | `ingest_commit_batch_messages` | `sources.ingest_commit_batch_messages` | Messages per commit batch during ingest (default 8000). | | `ingest_parse_workers` | `POLYLOGUE_INGEST_PARSE_WORKERS` (env only) | Worker count for CPU-bound source parsing. Read from the environment; there is no TOML key. The default adapts to the interpreter — `min(16, cpus-2)` on a free-threaded build (the packaged daemon), `min(8, cpus-1)` under the GIL. Set to `1` to disable pooling. | | `live_full_ingest_workers` | `sources.live_full_ingest_workers` | Parallel workers for a live full-reingest pass (default 1). | -| `raw_authority_commit_batch_size` | `pipeline.raw_authority.commit_batch_size` | Census-phase commit batch size for raw-materialization repair (polylogue-amg1); unset uses the built-in default, `<=0` disables batching (per-raw commits). | +| `raw_authority_commit_batch_size` | `pipeline.raw_authority.commit_batch_size` | Census-phase commit batch size for raw materialization; unset uses the built-in default, `<=0` disables batching (per-raw commits). | | `raw_authority_whale_payload_bytes` | `pipeline.raw_authority.whale_payload_bytes` | Escalation-tier payload envelope (bytes) for the daemon whale pass (polylogue-t93b); unset/`<=0` uses the built-in default (8 GiB). Widens the resource-block envelope for one dedicated, stream-safe-gated single-component pass only -- the ordinary fast-path envelope is unaffected. | | `daemon_parse_stage_workers` | `daemon.raw_materialization.parse_stage_workers` | Worker cap for the daemon-owned pre-parse thread pool (polylogue-m6tp phase (a); always runs -- pre-parses raw-materialization census candidates in a bounded thread pool before the writer hold); unset/`<=0` uses the adaptive `cpu_count - 1` default. | | `daemon_parse_stage_max_inflight_bytes` | `daemon.raw_materialization.parse_stage_max_inflight_bytes` | Whale-memory budget (bytes) for raw payloads admitted while prefetch parses are in flight; unset/`<=0` uses the adaptive 1/16-physical-RAM default (clamped [64 MiB, 2 GiB]). | @@ -400,7 +400,7 @@ Common runtime overrides: | `POLYLOGUE_CREDENTIAL_PATH` | Drive auth | OAuth client JSON path. | | `POLYLOGUE_TOKEN_PATH` | Drive auth | OAuth token path. | | `POLYLOGUE_HOOK_PROVIDER` | `hook_provider` | Force hook-harness detection to `claude-code`/`codex`. | -| `POLYLOGUE_RAW_AUTHORITY_COMMIT_BATCH_SIZE` | `raw_authority_commit_batch_size` | Census-phase commit batch size for raw-materialization repair. | +| `POLYLOGUE_RAW_AUTHORITY_COMMIT_BATCH_SIZE` | `raw_authority_commit_batch_size` | Census-phase commit batch size for raw materialization. | | `POLYLOGUE_RAW_AUTHORITY_WHALE_PAYLOAD_BYTES` | `raw_authority_whale_payload_bytes` | Escalation-tier payload envelope for the daemon whale pass. | | `POLYLOGUE_DAEMON_PARSE_STAGE_WORKERS` | `daemon_parse_stage_workers` | Worker cap for the daemon-owned pre-parse thread pool. | | `POLYLOGUE_DAEMON_PARSE_STAGE_MAX_INFLIGHT_BYTES` | `daemon_parse_stage_max_inflight_bytes` | In-flight raw-payload budget for the prefetch cache. | @@ -482,9 +482,7 @@ output. ### Health Checks - `polylogue ops doctor` validates config, archive root, DB reachability, index status, and Drive credential/token presence. -- `polylogue ops doctor --repair` runs safe derived-data maintenance. -- `polylogue ops doctor --cleanup` runs destructive archive cleanup; preview it first. -- `polylogue ops doctor --repair --vacuum` compacts the database after maintenance. +- `polylogue ops doctor --deep` adds SQLite integrity and exact orphan scans. - Workstation-specific policy such as cgroup slice placement and hard caps belongs in the host environment, not in the product CLI. --- diff --git a/docs/cost-model.md b/docs/cost-model.md index 52fbf18036..23a63e9cdf 100644 --- a/docs/cost-model.md +++ b/docs/cost-model.md @@ -2,7 +2,7 @@ Polylogue tracks per-session and per-cycle AI cost as a typed, multi-basis estimate. This page explains the basis taxonomy, subscription plan -configuration, cycle outlook semantics, the single-basis backfill path, and the +configuration, cycle outlook semantics, single-basis rows, and the non-authoritative caveat that governs every number displayed. > **Non-authoritative.** Polylogue is not a billing system. Subscription @@ -382,35 +382,14 @@ All three surfaces share the same typed `CycleOutlook` envelope. The CLI plain renderer visibly labels subscription-quota math as non-authoritative and tags estimated USD figures. -## Single-Basis Backfill +## Single-Basis Rows Session-profile rows materialized before the basis split (#1136) carry a populated `total_cost_usd` column but lack the per-basis values in -their evidence payload. The backfill helper in -`polylogue/maintenance/cost_backfill.py` identifies those rows and -schedules a typed rebuild: - -1. `find_single_basis_cost_rows(reader)` selects rows whose `cost_provenance` - is in `SINGLE_BASIS_COST_PROVENANCE_MARKERS` (currently `{"unknown", ""}`) - and whose `total_cost_usd` is strictly positive. Already-typed - provenance values (`provider_reported`, `mixed`) are intentionally - excluded. -2. `plan_cost_backfill(rows)` returns a typed - `BackfillOperation` with `kind = DERIVED_REBUILD`, - `targets = ('session_profiles',)`, - `reason = STALE_MATERIALIZER_VERSION`, and a scope filter carrying - the source tag `single-basis-cost` plus the session-id list. -3. The maintenance planner - (`polylogue/maintenance/planner.py:execute_backfill`) consumes the - operation and re-materializes the affected session profiles. The - rebuilt rows carry the basis split via the standard #1136 path; the - source tag flows through the rebuild's - `ArchiveInsightProvenance` so downstream surfaces can render *why* - the row was rebuilt. - -The backfill is one-shot and idempotent: a rebuilt row no longer -matches the stale provenance markers, so a second pass detects zero -candidates. +their evidence payload. The session-insight materializer +(`polylogue/storage/insights/session/profile_cost.py`) re-derives the +basis split whenever the daemon's insights convergence stage rebuilds a +profile row, so such rows disappear through ordinary convergence. ## Caveats diff --git a/docs/daemon-concurrency-profile.md b/docs/daemon-concurrency-profile.md index ec7965d0fa..6a4ae1bc72 100644 --- a/docs/daemon-concurrency-profile.md +++ b/docs/daemon-concurrency-profile.md @@ -48,8 +48,5 @@ the typed operation profile. | added | 1259 | 493 tracked additions plus 766 lines in eight new files, including tests/docs/tooling. | | net maintained | 1198 | Added minus gross deleted; no physical file relocation was claimed. | -The sharded rebuild implementation is not deleted in this lane because its -current callers and dedicated provenance laws remain live in this checkout; -the packet's historical K=4/K=8 result is recorded as a rejected optimization, -not as evidence that those callers are abandoned. A follow-up deletion must -remove its option, callers, and oracle together. +The packet's historical K=4/K=8 sharded-rebuild result is recorded as a +rejected optimization; the sharded rebuild and its callers no longer exist. diff --git a/docs/daemon.md b/docs/daemon.md index 637986cf92..c4bfbf0649 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -116,7 +116,7 @@ classified with explicit auth and response posture. | Stable read/query API | bearer or scoped web credential when configured | `GET /api/sessions`, `GET /api/query-units`, `GET /api/sessions/:id`, `GET /api/sessions/:id/read`, `GET /api/assertions`, `GET /api/sessions/:id/provenance` | | User overlay reads | bearer or scoped web credential when configured | `GET /api/user/marks`, `GET /api/user/saved-views/:id` | | Browser-accessible user-state mutations | bearer or scoped web credential plus exact-origin browser request | `POST /api/user/marks`, `DELETE /api/user/saved-views/:id` | -| Archive control mutations | machine bearer when configured plus exact-origin browser request | `POST /api/reset`, `POST /api/ingest`, `POST /api/maintenance/run` | +| Archive control mutations | machine bearer when configured plus exact-origin browser request | `POST /api/reset`, `POST /api/ingest` | | WebUI observability reads | bearer or scoped web credential when configured | `GET /api/webui/observability`, `GET /api/webui/insights/:name`, `GET /api/webui/freshness?source=...` | User overlay mutation routes return the shared mutation result envelope diff --git a/docs/design/convergence-simplification-inventory.md b/docs/design/convergence-simplification-inventory.md index bda64d647e..ccc6ea5dbd 100644 --- a/docs/design/convergence-simplification-inventory.md +++ b/docs/design/convergence-simplification-inventory.md @@ -427,64 +427,10 @@ generation building is in play); once it exists, the per-pass ### 6. The CLI bulk importer's operator-surface status -**What it is:** `polylogue ops maintenance rebuild-index` -(`polylogue/cli/commands/maintenance/_rebuild_index.py:281` -`@click.command("rebuild-index")`, handler `rebuild_index_command` at `:349`) -— today a live operator tool: #3145's daemon-side loud recommendation -(`polylogue/daemon/cli.py` `_maybe_recommend_bulk_rebuild`) tells an operator -to run it by hand when the trickle conveyor's backlog is bulk-scale, and the -2026-07-19 restore incident (polylogue-5jak notes) used it directly as the -only viable path once the daemon's own conveyor made a live backlog -net-negative. - -**Companion status surface (polylogue-b5l.1):** -`polylogue ops maintenance rebuild-index-status` -(`polylogue/cli/commands/maintenance/_rebuild_index_status.py`, -handler `rebuild_index_status_command`) reports the consolidated, -read-only view an operator needs while `rebuild-index` (or the daemon's own -bulk-rebuild loop) is running or paused: archive-root lease ownership -(held/holder pid/host/liveness/staleness), the active generation, the active -index's schema version, and the resumable transaction's cursor -(`processed_raw_count`/`last_raw_id`/`updated_at_ms`) alongside a -source-snapshot delta and explicit stale-lock/failed-transaction recovery -guidance (`polylogue.maintenance.rebuild_index.rebuild_status`). It never -acquires the rebuild lease itself. - -**Why it exists today:** it is the one code path that already does the -right thing for a bulk backlog — one resumable transaction, blue-green -generation, full parse envelope, one census+replay sweep — because it does -not share the daemon's live-ingest constraints (no concurrent watcher, no -per-tick writer-sharing budget, can run with the daemon stopped). - -**What makes it deletable as an *operator* tool (not as code):** polylogue-m6tp's -2026-07-19 operator direction states the target plainly: "with free-threaded -3.14t ... normal daemon convergence could BE the fast path, making the CLI -bulk importer unnecessary for ordinary backlogs." Phase (c)'s in-process -blue-green generation building (an inactive generation on a second writer -connection, live ingest continuing on the active index, promoted via the -existing generation-store pointer swap) gives the daemon itself everything -`rebuild-index` does today, without stopping the daemon or freezing the -source. Once that lands, an operator should never need to invoke -`rebuild-index` for routine backlog drains. - -**What survives:** the *machinery*, not the operator surface. The resumable, -transactional, blue-green-generation implementation in -`polylogue/maintenance/rebuild_index.py` becomes daemon-internal — phase (c) -invokes it from convergence routing. The `ops maintenance rebuild-index` CLI -command is **deleted** once the daemon path is proven equivalent -(polylogue-gd6v acceptance). There is no break-glass tier (operator doctrine, -2026-07-19): a redundant manual surface kept "just in case" is exactly the -random-machinery packing this codebase aggressively purges. The scenarios -that seemed to justify one dissolve on inspection — a daemon that cannot run -is a bug to fix in the daemon, and a corrupted/mismatched derived tier is -already the daemon's own rebuild-on-mismatch invariant (the derived-tier -schema regime). Read-only *diagnostic* inspection surfaces may survive; -nothing that mutates does. - -**Which phase deletes/collapses it:** (c) lands the daemon routing and, in -the same change-train once equivalence is proven, deletes the CLI command, -its Click plumbing, and its operator documentation. No confirmation flags, -no deprecation period, no alias. +Resolved: `polylogue ops maintenance rebuild-index`, `rebuild-index-status`, +`reindex-canary`, the `maintenance/rebuild_index.py` engine, and the daemon's +bulk-rebuild routing are deleted. Ordinary daemon convergence is the only +build path. ## What phase (a) (this PR) does NOT touch diff --git a/docs/internals.md b/docs/internals.md index 2d01a645d5..cc14f9e882 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -1221,7 +1221,7 @@ Cross-check adjacent surfaces after changes: - query: `cli/query*.py` ↔ `archive/filter/filters.py` ↔ `storage/search*.py` - pipeline: `daemon/` ↔ `pipeline/` ↔ `storage/` ↔ `insights/` -- maintenance: `cli/commands/check.py` ↔ `storage/repair.py` ↔ `health.py` +- readiness: `cli/commands/check.py` ↔ `readiness/` ↔ `daemon/health.py` - publication: `rendering/` ↔ `site/` ↔ `devtools/` - schema: `schemas/` ↔ `sources/providers/` ↔ `pipeline/services/validation_*` diff --git a/docs/library-api.md b/docs/library-api.md index 23e2dd0484..7ada30505f 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -142,7 +142,7 @@ profile evidence and probabilistic enrichment in one payload. Archive coverage and archive debt are public insights too: - `ArchiveCoverageInsight`: provider/day/week session, message, cost, and activity coverage rollups -- `ArchiveDebtInsight`: governed cleanup/repair debt with maintenance targets plus preview/apply/validation lineage +- `ArchiveDebtInsight`: derived-tier debt rows (FTS, profile, materialization, raw-link, user-overlay coverage) with issue counts ## Filter Chain API diff --git a/docs/maintenance.md b/docs/maintenance.md index 7c2c292efc..8f4a98ce62 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -1,10 +1,12 @@ # Maintenance -This guide is for operators choosing between -`polylogue ops maintenance preview`, `polylogue ops maintenance plan`, -`polylogue ops maintenance run`, `polylogue ops reset`, and "do nothing — the -daemon will catch up." It also collects runbook recipes for the most -common operational incidents. +This guide is for operators choosing between the read-only inspection +verbs under `polylogue ops maintenance`, the guarded durable-evidence +recovery verbs, `polylogue ops reset`, and "do nothing — the daemon will +catch up." It also collects runbook recipes for the most common +operational incidents. Derived state (`index.db`, FTS, insights, +embeddings) is rebuilt only by ordinary daemon convergence; there is no +manual rebuild or repair verb. ## Applying a durable schema change train @@ -75,23 +77,11 @@ After this bridge commits, create and verify a fresh `full_evidence` backup at t Later authenticated source maintenance writes a typed refresh receipt that binds its predecessor authority and the exact durable-train manifest hashes before and after the refresh. Repeated relocations and intervening refreshes therefore validate as one unbranched transition chain ending at the exact current manifest; matching only the current source hash or archive identity is not authority. -### Rebuild deployment-currency preflight +### Deploying a package that owns the live durable schemas -Before a managed `rebuild-index`, confirm that the package selected for the -operation owns the live durable schemas. The read-only preflight checks every -canonical durable migration tier: `source.db`, `user.db`, and `audit.db`. -`index.db` may be behind because the rebuild is the supported way to replace -that derived tier. - -```bash -polylogue ops maintenance rebuild-index --preflight --output-format json -``` - -It emits `rebuild-schema-currency` JSON with each durable tier's observed and -package-expected `user_version`, and exits nonzero when a durable tier differs. -The execution route checks it before consuming the schema-inference receipt, -repeats it after archive ownership acquisition, and rejects daemon bulk -transaction creation before any bookkeeping or candidate generation. +Confirm that the package being deployed owns the live durable schemas +(`source.db`, `user.db`, `audit.db`). `index.db` may be behind: the daemon +rebuilds that derived tier from source through ordinary convergence. For a safe deployment recovery, first choose the exact target package commit. With the daemon stopped, create a fresh verified full-evidence backup. If the @@ -127,118 +117,31 @@ polylogue ops maintenance migrate-tier user --backup-manifest /path/to/verified- polylogue ops maintenance migrate-tier audit --backup-manifest /path/to/verified-full-backup/manifest.json --output-format json ``` -Deploy that exact package after every required durable migration. Run the -preflight again and require a ready result before invoking `polylogue ops -maintenance rebuild-index`; use that blue-green command rather than `ops reset ---index` for an active managed generation. Restart the daemon only after the -rebuilt generation is promoted and the post-deploy status shows no durable-tier -mismatch. +Deploy that exact package after every required durable migration, then +start the daemon; ordinary convergence rebuilds `index.db` from `source.db`. +`polylogue ops status` must show no durable-tier mismatch after the deploy. For the conceptual model behind derived insights and the FTS / blob substrate, see [architecture.md](architecture.md) and [internals.md](internals.md). For daemon ownership of the inline maintenance loop, see [daemon.md](daemon.md). -## What a maintenance operation is - -A *maintenance operation* is an explicit, resumable, idempotent pass -over already-ingested archive state. It does **not** acquire new -source data. It does **not** rewrite or delete imported sessions -beyond targeted cleanup. It rebuilds, repairs, or prunes the things -the archive depends on but does not own as primary data: - -- derived read models (session insights, actions, work threads, - day/week summaries, message-type classifications); -- search indexes (the FTS5 projections over messages and action - events); -- backfilled columns (e.g. `message_type` for rows ingested before the - classifier existed); -- archive-cleanup scopes (orphaned messages, orphaned content blocks, - empty sessions, orphaned attachments); -A WAL checkpoint is not a maintenance operation. Ingest runs bounded passive -checkpoints after commits, the daemon runs periodic truncate checkpoints, and -status/metrics report WAL pressure. If WAL stays large after those automatic -paths have had a chance to run, treat that as a daemon/storage bug rather than a -user-facing repair verb. +## Ownership -A maintenance operation is distinguished from three adjacent things: - -| Surface | What it does | When you reach for it | -| --- | --- | --- | -| **Import** (`polylogued`, `polylogue import PATH`) | Daemon acquires source payloads, parses provider records, writes archive rows, and advances derived models *for the new rows*. `polylogue import PATH` asks the running daemon to schedule an explicit file or directory. | You have new exports/sessions to import. | -| **Daemon convergence** (`polylogued` inline loops) | Performs the same operations as ingest plus automatic WAL checkpointing, FTS convergence, heartbeat, health checks, and embedding/profile catch-up. | The daemon is running. You do nothing. | -| **Maintenance** (`polylogue ops maintenance ...`) | Rebuilds derived state and prunes archive debt over already-ingested rows. Read-only by default; mutations are explicit. | A derived model is stale or missing for old rows that the daemon's small inline windows will not pick up. | -| **Reset** (`polylogue ops reset`) | Deletes data: the SQLite database, the blob store, attachments, cache, OAuth tokens, or named sessions (soft-delete via tombstones). | The data itself is wrong or unwanted, not just a derived projection of it. | +`polylogued` owns every archive write. Its convergence loops drain raw +materialization, FTS, embeddings, and derived read models in bounded +batches from durable source evidence; a stale or missing derived row is +converged, never repaired by hand. The `ops maintenance` verbs below are +read-only inspection, or guarded, receipted recovery of durable evidence +that convergence cannot re-derive (blob quarantine, archive-root +relocation, durable-tier migrations, raw-authority ledger recovery). +A WAL checkpoint is not a maintenance operation: ingest runs bounded passive +checkpoints after commits, the daemon runs periodic truncate checkpoints, and +status/metrics report WAL pressure. -The order of preference is: **do nothing → daemon → maintenance → +The order of preference is: **do nothing → daemon → guarded recovery → reset**. Reset is the only one that destroys primary data. -## Typed scopes - -Maintenance targets are grouped into four scopes: - -| Scope | Mode | Destructive | Targets | -| --- | --- | --- | --- | -| `derived` (derived_repair) | repair | no | `session_insights`, `message_type_backfill` | -| `archive_cleanup` | cleanup | **yes** | `empty_sessions` | -| `backfill` | repair | no | column/row backfills surfaced by the planner (currently subsumed by `derived`). Re-acquiring raw artifacts from source, WAL checkpointing, and repairing FTS coherence are daemon/ingest convergence responsibilities, not maintenance targets. | - -The canonical target list is enforced by -`polylogue/maintenance/targets.py`. The CLI `--target` option's -`click.Choice` is built from -`MAINTENANCE_TARGET_NAMES`, so the source is the type system: an -unknown target is rejected at the CLI boundary. - -## When to use which surface - -```text - something looks off - | - +--------------------+--------------------+ - | | - one or a few sessions wide swath of the archive - look wrong / outdated looks stale (FTS misses - | hits, session profiles - | missing for old data, ...) - | | - polylogue ops maintenance preview polylogue ops maintenance preview - (scoped to that session) (no scope — full inventory) - | | - nothing stale? nothing stale? - - the data really is that way. - the daemon already converged. - stop. open an issue with a stop. nothing to do. - concrete acceptance criterion. - | | - stale rows reported? stale rows reported? - polylogue ops maintenance run polylogue ops maintenance run - --session (no scope) or - [--target ...] --target - | | - still wrong? the data itself failures reported? - is wrong, not the projection. inspect failure_samples, - polylogue ops reset --session re-run with --operation-id - (tombstones it; preserves to resume from cursor. - identity ledger for re-import) -``` - -Heuristics: - -- **Preview before plan, plan before run.** `preview` is read-only; - `plan` is a dry-run summary; `run-preview` is a heavier, resumable - dry run that exercises the real execution path; `run` is the only - mutating verb. -- **Prefer the narrowest target.** `--target session_insights` is - cheaper and safer than rebuilding everything. -- **Do not reach for `reset` to "fix a stale projection."** That is - what `maintenance` is for. Reset destroys the primary data the - projection is built from. -- **If the daemon is running and the issue is recent**, wait one - convergence cycle (~10 minutes for FTS, ~5 minutes for WAL) and - re-check before reaching for maintenance. -- **If maintenance reports zero stale rows but the archive still - looks wrong**, the bug is upstream (ingest, parser, schema) — open - an issue, do not loop on maintenance. - ## Subcommands ### `polylogue ops maintenance blob-namespace-quarantine`: offline filesystem quarantine @@ -329,31 +232,6 @@ reclassifies under `BEGIN IMMEDIATE` before fsyncing the prepared receipt and deleting the exact candidate set. Review the receipt's final `committed` line before treating the pass as complete. -### `polylogue ops maintenance blob-reference-closure` - acquired reference closure - -Read-only by default. It checks that each `raw_sessions` row has exactly one -matching `raw_payload` ref and that each acquired index attachment is reachable -through `attachment_refs`. Raw gaps are repaired from the retained raw row's -exact hash, path, size, and acquisition timestamp. Attachment gaps are repaired -only when a complete reparse of authoritative `source.db` bytes reproduces the -attachment identity and its owning message still exists. Other rows are -reported as typed blockers and remain untouched. - -```bash -polylogue ops maintenance blob-reference-closure --output-format json -polylogue ops maintenance blob-reference-closure --apply \ - --backup-manifest /path/to/verified-full-evidence-manifest.json \ - --receipt-file /path/to/new/blob-reference-closure.jsonl \ - --output-format json -``` - -Apply requires the daemon to be offline, a verified backup manifest covering -both `source.db` and `index.db`, and a new receipt path. It inserts exact refs -only, never deletes or replaces existing refs. The source and index commits are -recorded separately in the receipt so a retry can safely continue an additive -repair. Reindex acceptance runs the same closure check against the candidate -index before promotion. - ### `polylogue ops maintenance hook-payload-ref-reconcile` - legacy hook-ref repair Read-only by default. It classifies historical orphaned `raw_payload` refs and @@ -380,73 +258,12 @@ Deletion trigger: retire this apply command and its tests after a read-only census reports zero orphaned `raw_payload` refs and zero hook events with a missing `blob_hash`. The matcher remains while blob liveness and GC consume it. -### `polylogue ops maintenance preview` — staleness inventory - -Read-only. Produces a per-model inventory of stale, missing, orphan, -or version-mismatched rows tagged with a typed `InvalidationReason` -(`missing`, `stale`, `orphan`, `missing_provenance`, -`version_mismatch`, `orphan_archive_row`). Models with nothing stale -produce explicit zero rows rather than being absent from the output. - -```bash -polylogue ops maintenance preview -polylogue ops maintenance preview --scope derived -polylogue ops maintenance preview --scope archive_cleanup --output-format json -polylogue ops maintenance preview --shallow # skip expensive full-verification path -``` - -Use this before triggering `run` so you know what would be touched -and why. A write-watching SQLite hook in the test suite confirms zero -writes during a preview pass. - -### `polylogue ops maintenance plan` — dry-run summary - -Read-only. Resolves targets, evaluates affected rows, and produces a -`BackfillOperation` envelope without executing any repair. Use it to -sanity-check what the next `run` will do. - -```bash -polylogue ops maintenance plan -polylogue ops maintenance plan --target session_insights --target message_type_backfill -polylogue ops maintenance plan --output-format json | jq . -``` - -`--output-format json` emits the shared -`MaintenanceOperationEnvelope` so the CLI output is byte-for-byte -identical to the daemon HTTP and MCP responses. - -### `polylogue ops maintenance run-preview` — resumable dry run - -Read-only. Runs the exact same resumable replay path as `run` -- -including per-target repair simulation and checkpoint tracking -- but -never mutates the archive. This is a heavier, more faithful dry run than -`plan`: it exercises the same code each target's real `execute` step would -take, not just an affected-row estimate. Use it to combine the safety of -`plan` with the full execution-path code before committing to `run`. - -```bash -polylogue ops maintenance run-preview -polylogue ops maintenance run-preview --target session_insights --output-format json -``` - -### `polylogue ops maintenance run` — execute - -Runs the resolved targets. Per-target failures are isolated as -`FailureSample` entries; one failing target does not abort the rest. -Pass `--operation-id ` together with `--resume` to pick up an -interrupted operation. This command always mutates; it carries no -`--dry-run` flag -- use `run-preview` for the read-only twin. - -```bash -polylogue ops maintenance run --target session_insights --output-format json -``` - ### Blob-reference integrity — preview and apply pairs `polylogue ops maintenance blob-reference-debt` and `blob-reference-recovery-plan` are read-only classification/planning commands. The two commands that can actually mutate the archive each -follow the same preview/apply split as `run`/`run-preview`: a dedicated +follow the same preview/apply split: a dedicated read-only `-preview` command with no `--yes`/`--apply` flag, and a lean apply command that always mutates. @@ -466,46 +283,13 @@ polylogue ops maintenance blob-reference-prune-orphans \ --quarantine-file /tmp/quarantine.jsonl --output-format json ``` -### `polylogue ops maintenance reindex-canary` — inactive-generation semantic diff - -Read-only with respect to the active index. Before a full reindex, this command selects a bounded representative set of sessions, rebuilds those raws into an inactive generation, and diffs the resulting sessions, messages, blocks, links, and derived rows against the active generation. It requires `--no-promote`. A run with observed differences writes an unreviewed durable report and exits non-zero. Re-run with `--review-manifest` to persist one classification per difference, then use `--consume-report` to validate the reviewed report and approve its evidence. Approval never authorizes promotion. Treat every difference as either an expected effect of a named repair or a newly discovered defect. It is a preflight gate, not a replacement for the full managed rebuild. - -```bash -polylogue ops maintenance reindex-canary \ - --archive-root /realm/tmp/polylogue-canary-archive \ - --input /realm/tmp/polylogue-canary-archive/index.db \ - --schema-inference-receipt /realm/tmp/schema-inference-gate-receipt.json \ - --sample 100 \ - --report /realm/tmp/polylogue-reindex-canary.json \ - --no-promote \ - --output-format json -``` - -After reviewing the observed identities printed by the failed run, persist the classifications and validate the report. Consumption is dispatched to the archive's running daemon, which holds the write coordinator while it verifies referenced raw-payload bytes through `BlobStore` and revalidates the source closure, candidate generation, receipt, and comparison immediately before approval. Membership rows and logical-source-key expansion are part of the receipt, so drift fails closed: - -```bash -polylogue ops maintenance reindex-canary \ - --archive-root /realm/tmp/polylogue-canary-archive \ - --input /realm/tmp/polylogue-canary-archive/index.db \ - --sample 100 \ - --report /realm/tmp/polylogue-reindex-canary.json \ - --review-manifest /realm/tmp/polylogue-reindex-reviews.json \ - --no-promote - -polylogue ops maintenance reindex-canary \ - --archive-root /realm/tmp/polylogue-canary-archive \ - --report /realm/tmp/polylogue-reindex-canary.json \ - --consume-report \ - --no-promote -``` - ### `polylogue ops maintenance verify-archive` — coherence gate Read-only. Runs a fixed registry of independent checks over the whole archive and reports each as `ok`/`warning`/`error`/`skip` plus evidence numbers, never just a boolean. This is the repeatable substitute for the -manual checklist an operator used to run by hand after a blue-green index -rebuild or a full restore — "does the archive prove its own restore?" +manual checklist an operator used to run by hand after an index reset +and reconvergence or a full restore — "does the archive prove its own restore?" ```bash polylogue ops maintenance verify-archive @@ -532,130 +316,6 @@ Exit code is non-zero when any check reports `error` (or, with `--strict`, temporarily busy under a concurrent rebuild — never aborts the rest; each check independently reports its own outcome. -### `--operation-id` and `--resume`: worked example - -Replay execution writes a small JSON state file under `/.maintenance-state/.json` after each target attempt. The state file is removed when the operation terminates successfully. New checkpoints treat `completed_targets` as the authoritative work coordinate. They retain `cursor="target:0"` as a validated migration field. Legacy positional cursors are remapped against their persisted target identities and fail closed when successful completion cannot be established. - -The operation ID is an opaque filename component. It must be a non-empty string without path separators, absolute-path syntax, NUL bytes, `.` or `..`. Omitting `--operation-id` generates a UUID. A supplied ID is reused exactly for state lookup and resume. - -```bash -# Start an operation, capture its id. -op=$(polylogue ops maintenance run --output-format json \ - --target session_insights \ - --target message_type_backfill \ - | jq -r .operation_id) - -# ... operation is killed mid-run (Ctrl-C, OOM, oncall reboot) ... - -# Resume from the persisted cursor — same id, same target set, no flag needed. -polylogue ops maintenance run --operation-id "$op" \ - --target session_insights \ - --target message_type_backfill - -# Explicit cursor override for a fresh or legacy positional state. -# New checkpoints use completed target identities instead of this position. -polylogue ops maintenance run --operation-id "$op" --resume target:2 \ - --target session_insights \ - --target message_type_backfill -``` - -When resuming a persisted operation, keep the execution mode and scope filter identical to the original request. A changed dry-run mode or a broader or narrower scope is rejected before any handler runs. A malformed or missing persisted cursor is a typed failure rather than a fresh execution. - -Two correctness guarantees the executor provides: - -1. **Convergence.** Running the same operation twice in a row produces - no additional changes after the first pass converges. The - underlying repair functions are idempotent by construction; the - replay loop adds the multi-target convergence guarantee. -2. **Resume integrity.** Targets already marked done in the state - file are skipped on resume, and no target is run twice. - -If the state file is missing and `--operation-id` is supplied without -`--resume`, the executor treats the id as a fresh start. - -## Scope filters - -The current shipping surface accepts repeatable `--session-id`, `--origin`, -`--source-family`, `--source-root`, `--since`/`--until`, `--failure-kind`, -and `--parser-version` filters. Each target decides which dimensions it can -honestly narrow; unsupported dimensions are preserved in the envelope but do -not pretend to reduce the affected-row count. - -```bash -polylogue ops maintenance run --session-id abc123 --target session_insights -polylogue ops maintenance run --origin claude --target session_insights -polylogue ops maintenance run --since 2026-04-01 --until 2026-05-01 \ - --target session_insights -polylogue ops maintenance run --failure-kind parse_error --target message_type_backfill -``` - -Until #1196 lands, the only way to narrow a run is through `--target` -and `--scope`. Do not script against flag names that are not yet on -`polylogue ops maintenance run --help`. - -## Status surface - -A long-running operation exposes its current cursor and in-flight -failure samples through three coherent surfaces: - -| Surface | How to read | -| --- | --- | -| CLI (`polylogue ops maintenance run`) | Progress lines printed to stderr each checkpoint: `[processed/total] target cursor=target:N failures=K`. The final stdout block reports `operation_id`, target results, elapsed time, and `Failures:` listing. | -| Daemon HTTP | `POST /api/maintenance/plan` and `POST /api/maintenance/run` return the same `MaintenanceOperationEnvelope` as the CLI. A dedicated `GET /api/maintenance/status/` endpoint is tracked in [#1197](https://github.com/Sinity/polylogue/issues/1197). | -| MCP | `maintenance_preview` and `maintenance_execute` return the same envelope as the CLI/HTTP. A `maintenance_status` tool is tracked in [#1197](https://github.com/Sinity/polylogue/issues/1197). | - -All three surfaces share the same `MaintenanceOperationEnvelope` -contract from `polylogue/maintenance/envelope.py`, so a `jq` script -that parses the CLI JSON also parses HTTP and MCP responses byte for -byte. The envelope carries `operation_id`, `status`, `targets`, -`resume_cursor`, `affected_rows`, `started_at`, `completed_at`, -per-target `results`, and a bounded `failure_samples` envelope. - -## Failure surface - -Replay failures are bounded by `BoundedFailureSamples` (a small -fixed cap per operation) so a runaway target cannot fill the -operation envelope with samples. Failures appear in three places: - -- **`polylogue ops maintenance run` stderr** — the final `Failures:` - block lists ` @ : ` for each captured - sample. A truncation marker is printed if the cap was hit. -- **`polylogue ops doctor` / `polylogue ops doctor`** — readiness reports - include maintenance-target readiness rows - (see `MaintenanceTargetSpec.doctor_readiness_operation` and - `doctor_repair_operation`). -- **Daemon raw-failure surface** — once - [#1198](https://github.com/Sinity/polylogue/issues/1198) lands, - maintenance failures will route into the same raw-failure surface - that ingest uses, so they show up in `polylogued` status, the - health checks added in - [#844](https://github.com/Sinity/polylogue/issues/844), and any - notification backend configured under `[notifications]`. - -If a replay fails repeatedly with the same `FailureSample.kind` and -`locator`, that is the signal to escalate from "re-run with -`--operation-id`" to "open an issue against the underlying repair -function." - -## Idempotency contract - -Re-running the same operation against unchanged input is a no-op. -Concretely: - -- `preview` is read-only; running it twice produces the same - inventory minus timing jitter. -- `plan` is read-only; running it twice produces the same envelope - modulo timestamps. -- `run` converges: the second `run` for the same target set against - unchanged source rows reports zero affected rows and zero failure - samples. This is enforced by repair functions being idempotent by - construction (see `polylogue/storage/repair.py`) plus the replay - loop's per-target convergence guarantee. - -The convergence guarantee is what makes resume safe: an interrupted -operation that already advanced past target *N* will not redo target -*N* on resume, and the redo would have been a no-op anyway. - --- ## Runbooks @@ -666,8 +326,8 @@ The runbooks below assume: [daemon.md § Operator-Owned Tasks](daemon.md#operator-owned-tasks)). - You can stop the daemon if a runbook requires exclusive write access (`systemctl --user stop polylogued.service`). -- You ran `polylogue ops maintenance preview` first to confirm the - symptom matches the runbook. +- You ran `polylogue ops doctor` first to confirm the symptom matches the + runbook. ### Recovering from a stale FTS index @@ -1098,9 +758,6 @@ polylogue import polylogue ops reset --session ``` -Do **not** reach for `polylogue ops maintenance run` to "fix" a stuck -source. Maintenance operates over already-ingested rows; if the rows -are not in the archive yet, maintenance has nothing to do. ### Recovering a corrupt blob store diff --git a/docs/onboarding.md b/docs/onboarding.md index 80cb33f7b4..dbb98c5e1d 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -91,15 +91,15 @@ records), run the health-check command: ```bash polylogue ops doctor -polylogue ops doctor --repair +polylogue ops doctor --deep polylogue ops doctor --runtime ``` `ops doctor` reports schema mismatches, FTS coverage gaps, blob-store -consistency, daemon liveness, and validation failures. `--repair` -recreates dropped FTS triggers and rebuilds the index where it can do -so safely; deeper recovery still requires the documented in-place -upgrade scripts described in [internals.md](internals.md#schema-versioning-model). +consistency, daemon liveness, and validation failures. It is read-only: +a running `polylogued` converges FTS, insights, and embeddings from +source evidence; schema recovery follows +[internals.md](internals.md#schema-versioning-model). For backup boundaries (SQLite WAL, blob store, service state), see [daemon.md](daemon.md). diff --git a/docs/openapi/search.yaml b/docs/openapi/search.yaml index 5323f7291d..f0a3d09803 100644 --- a/docs/openapi/search.yaml +++ b/docs/openapi/search.yaml @@ -4589,558 +4589,6 @@ components: description: The ``ranking_policy`` and ``ranking_policy_version`` fields on ``SearchEnvelope`` declare the ordering semantics in use. Consumers should pin a known version and treat any change as a contract event. x-polylogue-route-contracts: -- method: GET - pattern: / - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic archive overview HTML - notes: Canonical typed WebUI overview; browser data access remains behind the authenticated /api boundary. -- method: GET - pattern: /app - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic archive overview HTML - notes: SSR-first WebUI v2 strangler mount; Preact enhances only bounded continuation controls. -- method: GET - pattern: /app/observability - kind: browser_shell - stability: shell_supported - auth_policy: credential_if_configured - response_contract: semantic observability HTML - notes: SSR-first registry and status projection; credentials protect embedded insight evidence when configured. -- method: GET - pattern: /app/cost - kind: browser_shell - stability: shell_supported - auth_policy: credential_if_configured - response_contract: semantic cost/usage HTML - notes: SSR-first registry-driven cost rollup, usage timeline, and session drill-down; credentials protect embedded spend - evidence when configured. -- method: GET - pattern: /app/sessions - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic session list HTML - notes: SSR-first origin/date/repo faceted session list; Preact enhances only bounded pagination. -- method: GET - pattern: /app/sessions/:session_id - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic session read HTML - notes: 'SSR-first session shell: header, lineage banner, and a simple message-flow placeholder Preact enhances with paging.' -- method: GET - pattern: /app/search - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic search results HTML - notes: SSR-first ranked search over the shared SearchEnvelope; Preact enhances only cursor-based pagination. -- method: GET - pattern: /app/assets/:asset - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: manifest-governed immutable Vite asset - notes: Only content-hashed files named by the packaged Vite manifest are served. -- method: GET - pattern: /s/:session_id - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: semantic session read HTML - notes: Typed WebUI session deep-link; equivalent to /sessions/:session_id. -- method: GET - pattern: /w/:mode - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: text/html web shell - notes: Workspace shell bootstrap for registered workspace modes. -- method: GET - pattern: /p - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: text/html paste browser - notes: Standalone reader page; archive API calls remain authenticated. -- method: GET - pattern: /a - kind: browser_shell - stability: shell_supported - auth_policy: unauthenticated_loopback - response_contract: text/html attachment library - notes: Standalone reader page; archive API calls remain authenticated. -- method: GET - pattern: /healthz/live - kind: operational - stability: operational - auth_policy: unauthenticated_loopback - response_contract: health liveness JSON - notes: Unauthenticated for systemd/docker/kubernetes probes. -- method: GET - pattern: /healthz/ready - kind: operational - stability: operational - auth_policy: unauthenticated_loopback - response_contract: health readiness JSON - notes: Unauthenticated for systemd/docker/kubernetes probes. -- method: GET - pattern: /metrics - kind: operational - stability: operational - auth_policy: unauthenticated_loopback - response_contract: Prometheus text exposition - notes: Unauthenticated for Prometheus scrapers; no raw archive content. -- method: POST - pattern: /api/web-auth/session - kind: browser_shell - stability: shell_supported - auth_policy: first_party_same_origin - response_contract: WebCredentialBootstrapPayload - notes: Rotates a scoped HttpOnly credential; no credential bytes appear in the response body. -- method: DELETE - pattern: /api/web-auth/session - kind: browser_shell - stability: shell_supported - auth_policy: first_party_same_origin - response_contract: WebCredentialRevocationPayload - notes: Revokes the current first-party credential and expires its cookie. -- method: GET - pattern: /api/health/check - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: JSON -- method: GET - pattern: /api/health - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: JSON -- method: GET - pattern: /api/status - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: DaemonStatusPayload - notes: declaration=daemon.status; request=StatusQuery - domain_operation: daemon.status -- method: GET - pattern: /api/webui/observability - kind: observability - stability: shell_supported - auth_policy: credential_if_configured - response_contract: WebUI observability projection - notes: Registry descriptor fields, bounded rows, and the status-component snapshot adapter. -- method: GET - pattern: /api/webui/freshness - kind: observability - stability: shell_supported - auth_policy: credential_if_configured - response_contract: NamedSourceFreshness projection - notes: Requires one explicit source path and rejects archive-wide scans. -- method: GET - pattern: /api/overview - kind: read_query - stability: shell_supported - auth_policy: credential_if_configured - response_contract: bounded cockpit overview - notes: Privacy-safe landing aggregates, readiness, and a fixed recent-session page. -- method: GET - pattern: /api/events - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: SSE or JSON event poll -- method: GET - pattern: /api/agents/coordination - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: AgentCoordinationPayload - notes: Shared coordination envelope used by CLI, MCP, and the web mission-control projection. -- method: GET - pattern: /api/sessions - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: SearchEnvelope | SessionListResponse - notes: declaration=daemon.find.sessions; request=SessionSearchQuery - domain_operation: sessions.find -- method: POST - pattern: /api/cli/query - kind: read_query - stability: private - auth_policy: credential_if_configured - response_contract: SearchEnvelope / SessionListResponse with route_state - notes: Local UDS-only root-request parameter envelope; daemon owns query compilation. -- method: POST - pattern: /api/operation - kind: operational - stability: private - auth_policy: credential_if_configured - response_contract: DaemonOperationEnvelope - notes: Local CLI/MCP control-plane transport for archive-scoped read operations. -- method: POST - pattern: /api/cli/delete/prepare - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: delete preview envelope - notes: Local CLI transport; validates a bounded exact selection before entering writer authority. -- method: POST - pattern: /api/cli/delete/authorize - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: delete authorization envelope - notes: Local CLI transport; issues one daemon-held authorization for an authenticated preview owner. -- method: POST - pattern: /api/cli/delete/cancel - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: delete cancellation envelope - notes: Local CLI transport; cancels an unconfirmed daemon-held preview under the writer gate. -- method: POST - pattern: /api/cli/delete - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: MutationResultPayload - notes: Local CLI transport; consumes one daemon-held authorization under the writer gate. -- method: POST - pattern: /api/maintenance/rebuild-index - kind: maintenance - stability: operational - auth_policy: bearer_if_configured_and_same_origin - response_contract: RebuildIndexReceipt - notes: Runs exactly one source snapshot replay through the daemon write coordinator. -- method: POST - pattern: /api/maintenance/seal-canary-comparison - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: sealed canary comparison attestation - notes: Recomputes and immutably seals a candidate-owned historical comparison under the daemon writer gate. -- method: POST - pattern: /api/maintenance/consume-canary-report - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: approved canary report - notes: Validates report evidence while the daemon write coordinator owns the archive. -- method: POST - pattern: /api/maintenance/discard-index-candidate - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: inactive candidate discard receipt - notes: Reclaims one generation only when its immutable owner id still identifies an inactive candidate. -- method: GET - pattern: /api/facets - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: FacetsResponse with route-state metadata - notes: Repo and action facet families are deferred from first paint unless explicitly requested. -- method: GET - pattern: /api/query-units - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: QueryUnitResultEnvelope - notes: declaration=daemon.query.units; request=QueryUnitQuery - domain_operation: query.units -- method: GET - pattern: /api/provider-usage - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: ProviderUsageReport - notes: Usage-accounting diagnostics; separates provider events, cumulative counters, transcript words, and model rollups. -- method: GET - pattern: /api/archive-debt - kind: operational - stability: stable - auth_policy: credential_if_configured - response_contract: ArchiveDebtListPayload - notes: Unified archive debt rows shared by CLI, Python API, MCP, and daemon clients. -- method: GET - pattern: /api/import/explain - kind: operational - stability: shell_supported - auth_policy: credential_if_configured - response_contract: ImportExplainPayload - notes: Local import/source evidence explanation; paths are redacted unless explicitly requested. -- method: GET - pattern: /api/refs/resolve - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: PublicRefResolutionPayload -- method: GET - pattern: /api/query-completions - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: query completion metadata -- method: GET - pattern: /api/action-affordances - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: ActionAffordanceListPayload - notes: Shared query-action affordance inventory for CLI, daemon, and automation clients. -- method: GET - pattern: /api/read-view-profiles - kind: read_query - stability: stable - auth_policy: credential_if_configured - response_contract: read-view profile metadata -- method: GET - pattern: /api/assertions - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: AssertionClaimListPayload - notes: Read-only assertion-backed overlay claims shared by the web workbench and API clients. -- method: GET - pattern: /api/sources - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: source list JSON -- method: GET - pattern: /api/sessions/:id - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: Session detail JSON -- method: GET - pattern: /api/sessions/:id/messages - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: session messages JSON -- method: GET - pattern: /api/sessions/:id/read - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: SessionReadViewEnvelope - notes: declaration=daemon.read.session; request=SessionReadQuery - domain_operation: sessions.read -- method: GET - pattern: /api/sessions/:id/raw - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: raw session payload JSON - notes: Raw preview is opt-in and authenticated. -- method: GET - pattern: /api/sessions/:id/cost - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: cost JSON -- method: GET - pattern: /api/sessions/:id/evidence-summary - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: bounded session evidence summary - notes: Structural tool outcome counts, cost projection, and capped lineage refs for the transcript header. -- method: GET - pattern: /api/sessions/:id/provenance - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: provenance envelope - notes: Raw bytes require the include_raw query parameter. -- method: GET - pattern: /api/sessions/:id/topology - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: topology envelope -- method: GET - pattern: /api/sessions/:id/topology/parent-chain - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: parent-chain topology envelope -- method: GET - pattern: /api/sessions/:id/similar - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: similar-session envelope -- method: GET - pattern: /api/sessions/:id/attachments - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: session attachment envelope -- method: GET - pattern: /api/insights/sessions/:id - kind: read_detail - stability: stable - auth_policy: credential_if_configured - response_contract: session insights envelope -- method: GET - pattern: /api/webui/insights/:name - kind: observability - stability: shell_supported - auth_policy: credential_if_configured - response_contract: Single WebUI insight descriptor projection - notes: The daemon owns query construction and descriptor accessors; clients receive a bounded panel only. -- method: GET - pattern: /api/raw_artifacts/:id - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: raw artifact preview - notes: Authenticated raw preview helper for the local shell. -- method: GET - pattern: /api/thread-continue-templates - kind: read_detail - stability: shell_supported - auth_policy: credential_if_configured - response_contract: thread continuation templates -- method: GET - pattern: /api/paste-browser - kind: read_query - stability: shell_supported - auth_policy: credential_if_configured - response_contract: paste browser JSON -- method: GET - pattern: /api/attachments - kind: read_query - stability: shell_supported - auth_policy: credential_if_configured - response_contract: attachment library JSON -- method: GET - pattern: /api/stack - kind: workspace - stability: shell_supported - auth_policy: credential_if_configured - response_contract: stack workspace JSON -- method: GET - pattern: /api/compare - kind: workspace - stability: shell_supported - auth_policy: credential_if_configured - response_contract: compare workspace JSON -- method: GET - pattern: /api/user/marks - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: marks JSON -- method: GET - pattern: /api/user/annotations - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: annotations JSON -- method: GET - pattern: /api/user/annotations/:id - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: annotation JSON -- method: GET - pattern: /api/user/saved-views - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: saved views JSON -- method: GET - pattern: /api/user/saved-views/:id - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: saved view JSON -- method: GET - pattern: /api/user/recall-packs - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: recall packs JSON -- method: GET - pattern: /api/user/recall-packs/:id - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: recall pack JSON -- method: GET - pattern: /api/user/workspaces - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: workspaces JSON -- method: GET - pattern: /api/user/workspaces/:id - kind: user_overlay - stability: stable - auth_policy: credential_if_configured - response_contract: workspace JSON -- method: GET - pattern: /api/maintenance/operations - kind: maintenance - stability: stable - auth_policy: credential_if_configured - response_contract: maintenance operations JSON -- method: GET - pattern: /api/maintenance/status/:id - kind: maintenance - stability: stable - auth_policy: credential_if_configured - response_contract: maintenance operation status JSON -- method: POST - pattern: /api/telemetry/mcp-calls - kind: operational - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: MCP call-log receipt - notes: Machine-client telemetry; persisted by the daemon writer with bounded retention. -- method: POST - pattern: /api/reset - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: reset result JSON -- method: POST - pattern: /api/ingest - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: ingest result JSON -- method: POST - pattern: /api/demo/augment - kind: maintenance - stability: operational - auth_policy: bearer_if_configured_and_same_origin - response_contract: demo augmentation result JSON - notes: Applies deterministic demo writes through the write bridge; exists for the demo archive, not for general archive - mutation. -- method: POST - pattern: /api/maintenance/plan - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: maintenance operation preview -- method: POST - pattern: /api/maintenance/run - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: maintenance operation result - method: POST pattern: /api/user/marks kind: user_overlay diff --git a/docs/security.md b/docs/security.md index af7afaf369..a82bbd502b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -30,7 +30,7 @@ loopback ports. - Raw archive data (session content, raw artifacts, blob store) - Local filesystem paths surfaced via `/api/sources` -- Daemon control operations (`/api/reset`, `/api/ingest`, `/api/maintenance/*`) +- Daemon control operations (`/api/reset`, `/api/ingest`) ### Attack Surface @@ -45,7 +45,6 @@ loopback ports. | `/api/raw_artifacts/:id` | GET | Returns raw session payload | Bearer or web credential | Credential-bound | | `/api/reset` | POST | **Destructive** — resets archive state | Bearer only when auth is configured | Exact origin | | `/api/ingest` | POST | **Mutating** — schedules ingestion | Bearer only when auth is configured | Exact origin | -| `/api/maintenance/plan`, `/api/maintenance/run` | POST | **Mutating** — runs maintenance backfills | Bearer only when auth is configured | Exact origin | ## Mitigations diff --git a/polylogue/storage/index.py b/polylogue/storage/index.py deleted file mode 100644 index abc2480b17..0000000000 --- a/polylogue/storage/index.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import sqlite3 -from collections.abc import Sequence - -from polylogue.storage.fts.freshness import record_fts_invariant_snapshot_sync -from polylogue.storage.fts.fts_lifecycle import ( - _chunked as _chunked, -) -from polylogue.storage.fts.fts_lifecycle import ( - ensure_fts_index_sync, - fts_index_status_sync, - fts_invariant_snapshot_sync, - rebuild_fts_index_sync, - repair_fts_index_sync, -) -from polylogue.storage.search.cache import invalidate_search_cache -from polylogue.storage.sqlite.connection import connection_context, open_read_connection - - -def ensure_index(conn: sqlite3.Connection) -> None: - """Ensure the FTS5 table exists on the supplied connection.""" - ensure_fts_index_sync(conn) - - -def rebuild_index(conn: sqlite3.Connection | None = None) -> None: - """Rebuild the entire FTS5 search index from persisted message rows.""" - - def _do(db_conn: sqlite3.Connection) -> None: - rebuild_fts_index_sync(db_conn) - db_conn.commit() - invalidate_search_cache() - - with connection_context(conn) as db_conn: - _do(db_conn) - - -def update_index_for_sessions(session_ids: Sequence[str], conn: sqlite3.Connection | None = None) -> None: - """Repair FTS rows for specific sessions from persisted message rows.""" - changed = bool(session_ids) - - def _do(db_conn: sqlite3.Connection) -> None: - repair_fts_index_sync(db_conn, session_ids) - record_fts_invariant_snapshot_sync(db_conn, fts_invariant_snapshot_sync(db_conn)) - db_conn.commit() - if changed: - invalidate_search_cache() - - with connection_context(conn) as db_conn: - _do(db_conn) - - -def index_status(conn: sqlite3.Connection | None = None) -> dict[str, object]: - if conn is not None: - return fts_index_status_sync(conn) - with open_read_connection(None) as fallback_conn: - return fts_index_status_sync(fallback_conn) - - -__all__ = [ - "_chunked", - "rebuild_index", - "update_index_for_sessions", - "index_status", - "ensure_index", -] diff --git a/tests/benchmarks/test_pipeline.py b/tests/benchmarks/test_pipeline.py index a3c95a4222..b40b88cd4a 100644 --- a/tests/benchmarks/test_pipeline.py +++ b/tests/benchmarks/test_pipeline.py @@ -17,11 +17,11 @@ from polylogue.core.hashing import hash_payload, hash_text from polylogue.core.json import JSONDocument from polylogue.pipeline.semantic_metadata import extract_tool_metadata -from polylogue.storage.index import rebuild_index, update_index_for_sessions from tests.benchmarks.helpers import ( BenchmarkFixture, benchmark_connection_call, ) +from tests.infra.fts import rebuild_fts, repair_fts_for_sessions def _make_diverse_tool_inputs(n: int) -> list[tuple[str, JSONDocument]]: @@ -70,24 +70,24 @@ def test_bench_extract_tool_metadata(benchmark: BenchmarkFixture) -> None: @pytest.mark.benchmark def test_bench_fts_rebuild_1k(benchmark: BenchmarkFixture, bench_db_1k: Path) -> None: """FTS5 full rebuild on 1k messages.""" - benchmark_connection_call(benchmark, bench_db_1k, rebuild_index) + benchmark_connection_call(benchmark, bench_db_1k, rebuild_fts) @pytest.mark.benchmark def test_bench_fts_rebuild_5k(benchmark: BenchmarkFixture, bench_db_5k: Path) -> None: """FTS5 full rebuild on 5k messages — shows O(N) scaling.""" - benchmark_connection_call(benchmark, bench_db_5k, rebuild_index) + benchmark_connection_call(benchmark, bench_db_5k, rebuild_fts) @pytest.mark.benchmark @pytest.mark.parametrize("n", [1, 10, 50]) def test_bench_fts_incremental_update(benchmark: BenchmarkFixture, bench_db_5k: Path, n: int) -> None: - """update_index_for_sessions() for 1, 10, 50 sessions.""" + """FTS session repair for 1, 10, 50 sessions.""" ids = [f"bench-conv-{i:05d}" for i in range(n)] benchmark_connection_call( benchmark, bench_db_5k, - lambda conn: update_index_for_sessions(ids, conn), + lambda conn: repair_fts_for_sessions(ids, conn), ) diff --git a/tests/infra/fts.py b/tests/infra/fts.py new file mode 100644 index 0000000000..4774c374a8 --- /dev/null +++ b/tests/infra/fts.py @@ -0,0 +1,36 @@ +"""Test-side FTS rebuild helper over the production FTS lifecycle owner.""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Sequence + +from polylogue.storage.fts.freshness import record_fts_invariant_snapshot_sync +from polylogue.storage.fts.fts_lifecycle import ( + fts_invariant_snapshot_sync, + rebuild_fts_index_sync, + repair_fts_index_sync, +) +from polylogue.storage.search.cache import invalidate_search_cache +from polylogue.storage.sqlite.connection import connection_context + + +def rebuild_fts(conn: sqlite3.Connection | None = None) -> None: + """Rebuild the whole FTS5 index from persisted blocks on the configured archive.""" + with connection_context(conn) as db_conn: + rebuild_fts_index_sync(db_conn) + db_conn.commit() + invalidate_search_cache() + + +def repair_fts_for_sessions(session_ids: Sequence[str], conn: sqlite3.Connection | None = None) -> None: + """Repair FTS rows for specific sessions from persisted blocks.""" + with connection_context(conn) as db_conn: + repair_fts_index_sync(db_conn, session_ids) + record_fts_invariant_snapshot_sync(db_conn, fts_invariant_snapshot_sync(db_conn)) + db_conn.commit() + if session_ids: + invalidate_search_cache() + + +__all__ = ["rebuild_fts", "repair_fts_for_sessions"] diff --git a/tests/integration/test_workflows.py b/tests/integration/test_workflows.py index 2efc9fc1be..d2d89dabc0 100644 --- a/tests/integration/test_workflows.py +++ b/tests/integration/test_workflows.py @@ -126,11 +126,11 @@ async def test_full_workflow_per_provider( parse_result = await service.parse_sources([source]) # Build FTS index for search tests (INDEX stage of pipeline) - from polylogue.storage.index import update_index_for_sessions from polylogue.storage.sqlite.connection import open_connection + from tests.infra.fts import repair_fts_for_sessions with open_connection(db_path) as conn: - update_index_for_sessions(list(parse_result.processed_ids), conn) + repair_fts_for_sessions(list(parse_result.processed_ids), conn) # Verify import assert parse_result.counts["sessions"] > 0, f"No sessions imported from {provider}" @@ -592,11 +592,11 @@ async def test_search_accuracy_basic_terms(temp_config_and_repo: WorkflowRepos, await service.parse_sources([chatgpt_sample_source]) # Build search index - from polylogue.storage.index import rebuild_index from polylogue.storage.sqlite.connection import open_connection + from tests.infra.fts import rebuild_fts with open_connection(db_path) as conn: - rebuild_index(conn) + rebuild_fts(conn) # Get all sessions all_convs = await conv_repo.list() @@ -662,11 +662,11 @@ async def test_search_with_special_characters(temp_config_and_repo: WorkflowRepo await service.parse_sources([Source(name="test", path=path)]) # Build search index - from polylogue.storage.index import rebuild_index from polylogue.storage.sqlite.connection import open_connection + from tests.infra.fts import rebuild_fts with open_connection(db_path) as conn: - rebuild_index(conn) + rebuild_fts(conn) from polylogue.storage.search import search_messages diff --git a/tests/property/test_encoding_boundary_matrix.py b/tests/property/test_encoding_boundary_matrix.py index 5a30bb8b89..80b8779d41 100644 --- a/tests/property/test_encoding_boundary_matrix.py +++ b/tests/property/test_encoding_boundary_matrix.py @@ -37,10 +37,10 @@ class of bugs (e.g. the UTF-8 BOM bug fixed in ``polylogue.sources.decoder_json` ) from polylogue.sources.decoder_json import decode_json_bytes from polylogue.sources.parsers.base import ParsedMessage, ParsedSession -from polylogue.storage.index import rebuild_index from polylogue.storage.repository import SessionRepository from polylogue.storage.search import search_messages from polylogue.storage.search.query_support import escape_fts5_query +from tests.infra.fts import rebuild_fts from tests.infra.storage_records import make_message, make_session, save_current_archive_records # --------------------------------------------------------------------------- @@ -292,7 +292,7 @@ async def test_indexes_arabic_text( messages=[msg], attachments=[], ) - rebuild_index() + rebuild_fts() results = search_messages(ARABIC_HELLO, archive_root=workspace_env["archive_root"], limit=10) assert len(results.hits) == 1 assert results.hits[0].session_id == "claude-ai-export:conv-ar" @@ -315,7 +315,7 @@ async def test_indexes_cjk_text_without_crashing( messages=[msg], attachments=[], ) - rebuild_index() + rebuild_fts() # The English sentinel proves the row reached the FTS index even # though the CJK run itself is not substring-searchable. results = search_messages("cjkmarker", archive_root=workspace_env["archive_root"], limit=10) @@ -339,7 +339,7 @@ async def test_indexing_zero_width_and_bidi_does_not_crash( ) # Indexing must not raise. We do not assert on tokenizer-internal # decisions about whether ZWJ/ZWNJ split tokens. - rebuild_index() + rebuild_fts() async def test_indexes_nfc_and_nfd_independently( self, @@ -357,7 +357,7 @@ async def test_indexes_nfc_and_nfd_independently( messages=[msg], attachments=[], ) - rebuild_index() + rebuild_fts() results = search_messages(NFC_CAFE, archive_root=workspace_env["archive_root"], limit=10) assert len(results.hits) == 1 diff --git a/tests/unit/cli/conftest.py b/tests/unit/cli/conftest.py index ddda489a66..cdce8eeebf 100644 --- a/tests/unit/cli/conftest.py +++ b/tests/unit/cli/conftest.py @@ -7,7 +7,7 @@ import pytest -from polylogue.storage.index import rebuild_index +from tests.infra.fts import rebuild_fts from tests.infra.storage_records import DbFactory @@ -66,8 +66,6 @@ def search_workspace(cli_workspace: dict[str, Path], monkeypatch: pytest.MonkeyP updated_at=datetime.now() - timedelta(hours=6), ) - # Build FTS index using rebuild_index - - rebuild_index() + rebuild_fts() return cli_workspace diff --git a/tests/unit/cli/test_cli_output_schemas.py b/tests/unit/cli/test_cli_output_schemas.py index a402c10bef..1646b61a89 100644 --- a/tests/unit/cli/test_cli_output_schemas.py +++ b/tests/unit/cli/test_cli_output_schemas.py @@ -39,7 +39,7 @@ def _load_published_schema(name: str) -> dict[str, object]: def _seed_live_cli_schema_fixture(cli_workspace: dict[str, Path], monkeypatch: pytest.MonkeyPatch) -> None: """Seed a real archive row for live CLI output-schema checks.""" - from polylogue.storage.index import rebuild_index + from tests.infra.fts import rebuild_fts from tests.infra.storage_records import SessionBuilder, db_setup monkeypatch.setenv("XDG_STATE_HOME", str(cli_workspace["state_dir"])) @@ -55,7 +55,7 @@ def _seed_live_cli_schema_fixture(cli_workspace: dict[str, Path], monkeypatch: p .add_message(role="assistant", text="schema fixture response") .save() ) - rebuild_index() + rebuild_fts() def _invoke_live_cli(args: list[str], cli_workspace: dict[str, Path]) -> str: diff --git a/tests/unit/core/test_maintenance_targets.py b/tests/unit/core/test_maintenance_targets.py deleted file mode 100644 index 5f82594c17..0000000000 --- a/tests/unit/core/test_maintenance_targets.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from polylogue.maintenance.targets import ( - CLEANUP_TARGETS, - MAINTENANCE_TARGET_NAMES, - SAFE_REPAIR_TARGETS, - MaintenanceTargetMode, - build_maintenance_target_catalog, -) - - -def test_maintenance_target_catalog_groups_targets_by_mode() -> None: - catalog = build_maintenance_target_catalog() - - assert catalog.names() == MAINTENANCE_TARGET_NAMES - # SAFE_REPAIR_TARGETS is the doctor's ``--repair`` umbrella set: every - # REPAIR-mode target in the catalog. - repair_mode_targets = catalog.names_for_mode(MaintenanceTargetMode.REPAIR) - assert repair_mode_targets == SAFE_REPAIR_TARGETS - assert catalog.names_for_mode(MaintenanceTargetMode.CLEANUP) == CLEANUP_TARGETS - - -def test_maintenance_target_catalog_resolves_aliases_to_canonical_targets() -> None: - catalog = build_maintenance_target_catalog() - - spec = catalog.resolve_name("raw_snapshots") - - assert spec is not None - assert spec.name == "superseded_raw_snapshots" - - -def test_maintenance_target_catalog_reports_preview_and_help_semantics() -> None: - catalog = build_maintenance_target_catalog() - - assert catalog.preview_target_names() == ("session_insights",) - assert catalog.help_text() == ( - "Limit maintenance to named targets such as session_insights, empty_sessions, or superseded_raw_snapshots" - ) - - -def test_maintenance_target_catalog_exposes_archive_readiness_specs() -> None: - catalog = build_maintenance_target_catalog() - - assert tuple(spec.name for spec in catalog.archive_readiness_specs(deep=False)) == ("empty_sessions",) - assert tuple(spec.name for spec in catalog.archive_readiness_specs(deep=True)) == ( - "empty_sessions", - "superseded_raw_snapshots", - ) - - -def test_maintenance_target_catalog_renders_repair_hints_from_canonical_targets() -> None: - catalog = build_maintenance_target_catalog() - - assert catalog.repair_hint(("session_insights",), include_run_all=True) == ( - "Run `polylogue ops doctor --repair --target session_insights`, or `polylogued run`." - ) - assert catalog.resolve_name("dangling_fts") is None - assert catalog.resolve_name("raw_materialization") is None diff --git a/tests/unit/daemon/test_status_maintenance_failures.py b/tests/unit/daemon/test_status_maintenance_failures.py deleted file mode 100644 index 7c57bef05a..0000000000 --- a/tests/unit/daemon/test_status_maintenance_failures.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Tests for daemon status integration of maintenance failure routing (#1198). - -Pins the acceptance criteria: - -* ``_raw_failure_info()`` surfaces maintenance failures with - ``source="maintenance"`` and the originating ``operation_id`` - alongside live-ingest failures; -* ``DaemonStatus`` carries the maintenance count via - ``raw_maintenance_failures`` and the merged sample list; -* ``_check_raw_failures_medium`` escalates when maintenance failures - cross the existing thresholds, citing the operation id; -* the plain-text formatter emits the maintenance bucket and per-op - hint when the sample list is non-empty. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path -from unittest.mock import patch - -from polylogue.daemon.health import ( - HealthSeverity, - _check_raw_failures_medium, -) -from polylogue.daemon.status import ( - RawFailureSample, - _raw_failure_info, - format_daemon_status_lines, -) -from polylogue.maintenance.failure_routing import route_failure_sample -from polylogue.maintenance.planner import FailureSample -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - - -def _seed_raw_table(db: Path, parse_error: str | None = None, validation_status: str | None = None) -> Path: - """Seed the archive `source.db` ``raw_sessions`` table next to *db*. - - Returns the archive `index.db` sibling path so callers can patch - ``polylogue.daemon.status._active_status_db_path`` to resolve into this - archive root. - """ - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database - from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - - source_db = db.with_name("source.db") - index_db = db.with_name("index.db") - initialize_archive_database(source_db, ArchiveTier.SOURCE) - if parse_error is not None or validation_status is not None: - with sqlite3.connect(source_db) as conn: - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, blob_hash, blob_size, - acquired_at_ms, parse_error, validation_status, detection_warnings_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - "raw-1", - "claude-code-session", - "native-1", - "/x/y", - bytes(32), - 1, - 1_770_000_000_000, - parse_error, - validation_status, - "[]", - ), - ) - conn.commit() - return index_db - - -def _route(archive_root: Path, op_id: str, kind: str = "RuntimeError", message: str = "boom") -> None: - route_failure_sample( - FailureSample(kind=kind, locator="target:session_insights", message=message), - operation_id=op_id, - archive_root=archive_root, - target="session_insights", - ) - - -def test_raw_failure_info_surfaces_maintenance_with_no_db(tmp_path: Path) -> None: - """Maintenance failures appear even when the archive DB doesn't exist yet.""" - archive_root = tmp_path / "archive" - archive_root.mkdir(parents=True, exist_ok=True) - # The raw-failure lifecycle reads source.db; without a real archive it - # reports "source.db evidence is unavailable" and every severity - # assertion below degrades to ERROR regardless of the backlog seeded. - initialize_active_archive_root(archive_root) - _route(archive_root, op_id="op-1") - - db = tmp_path / "missing.db" - with ( - patch("polylogue.daemon.status._active_status_db_path", return_value=db), - patch("polylogue.daemon.status.archive_root", return_value=archive_root), - patch("polylogue.daemon.status._maintenance_failure_info") as mock_mf, - ): - # Re-route through real reader. - from polylogue.maintenance.failure_routing import ( - count_maintenance_failures, - read_maintenance_failures, - ) - - def _real() -> tuple[list[RawFailureSample], int, str | None]: - records = read_maintenance_failures(archive_root) - samples = [ - RawFailureSample( - failure_kind="maintenance", - provider_hint=r.target or None, - redacted_error=f"{r.kind}: {r.message}", - source="maintenance", - operation_id=r.operation_id, - locator=r.locator, - ) - for r in records - ] - # The third element is the ledger read error; None means the - # ledger was readable, which is this test's case. - return samples, count_maintenance_failures(archive_root), None - - mock_mf.side_effect = _real - info = _raw_failure_info() - - assert info["maintenance_failures"] == 1 - samples = info["samples"] - assert isinstance(samples, list) - assert len(samples) == 1 - assert samples[0].source == "maintenance" - assert samples[0].operation_id == "op-1" - assert samples[0].failure_kind == "maintenance" - - -def test_raw_failure_info_merges_ingest_and_maintenance(tmp_path: Path) -> None: - archive_root = tmp_path / "archive" - archive_root.mkdir(parents=True, exist_ok=True) - # Bootstrap before seeding: a durable tier that already exists when the - # archive is initialised is classified as established-with-unknown - # provenance, and durable admission then demands released train evidence a - # scratch archive can never have. - initialize_active_archive_root(archive_root) - # _seed_raw_table writes source.db beside the path it is given, and the - # lifecycle reads source.db under the patched archive_root -- seeding into - # tmp_path put the evidence in a sibling directory nothing reads. - index_db = _seed_raw_table(archive_root / "index.db", parse_error="JSONDecodeError at /tmp/foo") - _route(archive_root, op_id="op-mix-1") - _route(archive_root, op_id="op-mix-2", kind="ValueError", message="x") - - with ( - patch("polylogue.daemon.status._active_status_db_path", return_value=index_db), - patch("polylogue.daemon.status.archive_root", return_value=archive_root), - ): - info = _raw_failure_info() - - assert info["parse_failures"] == 1 - assert info["maintenance_failures"] == 2 - - samples = info["samples"] - assert isinstance(samples, list) - by_source = {s.source for s in samples} - assert by_source == {"ingest", "maintenance"} - - # The maintenance samples carry their operation_id. - maint = [s for s in samples if s.source == "maintenance"] - assert {s.operation_id for s in maint} == {"op-mix-1", "op-mix-2"} - - -def test_check_raw_failures_medium_escalates_on_maintenance(tmp_path: Path) -> None: - archive_root = tmp_path / "archive" - archive_root.mkdir(parents=True, exist_ok=True) - # The raw-failure lifecycle reads source.db; without a real archive it - # reports "source.db evidence is unavailable" and every severity - # assertion below degrades to ERROR regardless of the backlog seeded. - initialize_active_archive_root(archive_root) - for i in range(5): - _route(archive_root, op_id=f"op-batch-{i}") - - db = tmp_path / "index.db" - _seed_raw_table(db) - - with ( - patch("polylogue.daemon.status._active_status_db_path", return_value=db), - patch("polylogue.daemon.status.archive_root", return_value=archive_root), - ): - alert = _check_raw_failures_medium() - - assert alert.severity == HealthSeverity.WARNING - assert "5" in alert.message - assert "maintenance" in alert.message - assert "op=" in alert.message # op-id hint included - - -def test_check_raw_failures_medium_critical_on_large_maintenance_backlog(tmp_path: Path) -> None: - archive_root = tmp_path / "archive" - archive_root.mkdir(parents=True, exist_ok=True) - # The raw-failure lifecycle reads source.db; without a real archive it - # reports "source.db evidence is unavailable" and every severity - # assertion below degrades to ERROR regardless of the backlog seeded. - initialize_active_archive_root(archive_root) - for _ in range(60): - _route(archive_root, op_id="op-backlog") - - db = tmp_path / "index.db" - _seed_raw_table(db) - - with ( - patch("polylogue.daemon.status._active_status_db_path", return_value=db), - patch("polylogue.daemon.status.archive_root", return_value=archive_root), - ): - alert = _check_raw_failures_medium() - - assert alert.severity == HealthSeverity.CRITICAL - assert "60" in alert.message - - -def test_format_daemon_status_lines_renders_maintenance_bucket() -> None: - payload = { - # The renderer reports "unavailable" and suppresses the counts unless - # the lifecycle evidence says the source tier was actually read. - "raw_failure_lifecycle_available": True, - "raw_failure_lifecycle_state": "degraded", - "raw_parse_failures": 0, - "raw_validation_failures": 0, - "raw_quarantined": 0, - "raw_maintenance_failures": 3, - "raw_failure_samples": [ - { - "failure_kind": "maintenance", - "provider_hint": "session_insights", - "redacted_error": "RuntimeError: insight rebuild failed", - "source": "maintenance", - "operation_id": "op-12345678abcdef", - "locator": "target:session_insights", - }, - ], - } - lines = format_daemon_status_lines(payload) # type: ignore[arg-type] - rendered = "\n".join(lines) - assert "3 maintenance" in rendered - assert "[maintenance]" in rendered - assert "op=op-12345" in rendered # truncated id hint diff --git a/tests/unit/maintenance/test_envelope_contracts.py b/tests/unit/maintenance/test_envelope_contracts.py deleted file mode 100644 index 27d1644bc6..0000000000 --- a/tests/unit/maintenance/test_envelope_contracts.py +++ /dev/null @@ -1,390 +0,0 @@ -"""Cross-surface parity for the shared MaintenanceOperationEnvelope (#1149). - -The envelope is the single typed shape returned by: - -* CLI ``polylogue ops maintenance plan --output-format json`` / - ``polylogue ops maintenance run --output-format json``, -* daemon HTTP ``POST /api/maintenance/plan`` and - ``POST /api/maintenance/run``, -* MCP ``maintenance_preview`` and ``maintenance_execute``. - -These tests pin (1) the envelope itself — its fields, frozen-ness, -``origin``/``mode`` validation — and (2) that all three surfaces emit -byte-equal JSON for the same planner result. ``operation_id`` / -timestamps are derived from each surface's own planner call so the -parity assertion strips them before comparing; every other field is -required to match exactly. -""" - -from __future__ import annotations - -import asyncio -import json -from collections.abc import Iterator -from contextlib import contextmanager -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pytest -from click.testing import CliRunner -from pydantic import ValidationError - -from polylogue.cli.commands.maintenance import maintenance_group -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.envelope import ( - EnvelopeMode, - EnvelopeOrigin, - MaintenanceOperationEnvelope, - MaintenanceScopePayload, - envelope_from_operation, - envelope_keys, -) -from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - BoundedFailureSamples, - FailureSample, - MaintenanceScope, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter - -# --------------------------------------------------------------------------- -# Envelope shape pinning -# --------------------------------------------------------------------------- - - -EXPECTED_ENVELOPE_KEYS = frozenset( - { - "operation_id", - "kind", - "mode", - "origin", - "status", - "targets", - "scope", - "progress", - "started_at", - "completed_at", - "error", - "affected_rows", - "estimated_time_s", - "results", - "reason", - "resume_cursor", - "failure_samples", - "metrics", - } -) - - -class TestEnvelopeShape: - """Pin the typed envelope fields, frozen-ness, and validation rules.""" - - def test_envelope_keys_pinned(self) -> None: - assert envelope_keys() == EXPECTED_ENVELOPE_KEYS - - def test_envelope_is_frozen(self) -> None: - operation = _example_operation() - envelope = envelope_from_operation(operation, origin="cli", mode="preview") - with pytest.raises(ValidationError): - envelope.operation_id = "mutated" - - def test_envelope_forbids_extra_fields(self) -> None: - from polylogue.maintenance.envelope import ( - MaintenanceFailureSamplesPayload, - MaintenanceScopePayload, - ) - - scope = MaintenanceScopePayload(targets=(), filter={}) - failure_samples = MaintenanceFailureSamplesPayload(samples=(), truncated=False) - with pytest.raises(ValidationError): - MaintenanceOperationEnvelope( - operation_id="op", - kind="derived-rebuild", - mode="preview", - origin="cli", - status="pending", - targets=(), - scope=scope, - progress=0.0, - started_at=None, - completed_at=None, - error=None, - affected_rows=0, - estimated_time_s=0.0, - results=(), - reason=None, - resume_cursor=None, - failure_samples=failure_samples, - metrics={}, - surprise_field="boom", # type: ignore[call-arg] - ) - - @pytest.mark.parametrize("origin", ["cli", "daemon", "mcp"]) - def test_envelope_accepts_known_origins(self, origin: EnvelopeOrigin) -> None: - operation = _example_operation() - envelope = envelope_from_operation(operation, origin=origin, mode="preview") - assert envelope.origin == origin - - @pytest.mark.parametrize("mode", ["preview", "execute"]) - def test_envelope_accepts_known_modes(self, mode: EnvelopeMode) -> None: - operation = _example_operation() - envelope = envelope_from_operation(operation, origin="cli", mode=mode) - assert envelope.mode == mode - - def test_envelope_rejects_unknown_origin(self) -> None: - operation = _example_operation() - with pytest.raises(ValidationError): - envelope_from_operation(operation, origin="webhook", mode="preview") # type: ignore[arg-type] - - def test_envelope_rejects_unknown_mode(self) -> None: - operation = _example_operation() - with pytest.raises(ValidationError): - envelope_from_operation(operation, origin="cli", mode="rollback") # type: ignore[arg-type] - - def test_failure_samples_round_trip(self) -> None: - samples = BoundedFailureSamples.from_samples( - [FailureSample(kind="RuntimeError", locator="x.y", message="boom")] - ) - operation = _example_operation(failure_samples=samples) - envelope = envelope_from_operation(operation, origin="daemon", mode="execute") - payload = envelope.to_dict() - samples_payload: Any = payload["failure_samples"] - assert samples_payload["truncated"] is False - assert len(samples_payload["samples"]) == 1 - assert samples_payload["samples"][0] == { - "kind": "RuntimeError", - "locator": "x.y", - "message": "boom", - } - - -# --------------------------------------------------------------------------- -# Cross-surface parity — identical planner result through CLI, daemon, MCP. -# --------------------------------------------------------------------------- - - -@contextmanager -def _patched_preview(operation: BackfillOperation) -> Iterator[None]: - """Replace preview_backfill at every surface entry point.""" - with ( - patch( - "polylogue.cli.commands.maintenance._plan.preview_backfill", - return_value=operation, - ), - patch( - "polylogue.maintenance.planner.preview_backfill", - return_value=operation, - ), - ): - yield - - -def _strip_volatile_fields(envelope: dict[str, Any]) -> dict[str, Any]: - """Remove fields that each surface derives independently. - - ``operation_id`` and timestamps are minted per call; we are pinning - *structural* parity, not call-time identity. ``origin`` is the one - field that legitimately differs between surfaces. - """ - stripped = dict(envelope) - stripped.pop("operation_id", None) - stripped.pop("started_at", None) - stripped.pop("completed_at", None) - stripped.pop("origin", None) - return stripped - - -class TestCrossSurfaceParity: - """The same planner result must produce structurally identical envelopes.""" - - def test_preview_parity_across_cli_daemon_mcp(self, tmp_path: Path) -> None: - operation = _example_operation() - - # --- CLI --- - cli_payload = _capture_cli_preview(operation, tmp_path) - - # --- Daemon --- - daemon_envelope = envelope_from_operation(operation, origin="daemon", mode="preview") - daemon_payload = daemon_envelope.to_dict() - - # --- MCP --- - mcp_envelope = envelope_from_operation(operation, origin="mcp", mode="preview") - mcp_payload = mcp_envelope.to_dict() - - cli_stripped = _strip_volatile_fields(cli_payload) - daemon_stripped = _strip_volatile_fields(daemon_payload) - mcp_stripped = _strip_volatile_fields(mcp_payload) - - assert cli_stripped == daemon_stripped == mcp_stripped - # Origin tagged per surface. - assert cli_payload["origin"] == "cli" - assert daemon_payload["origin"] == "daemon" - assert mcp_payload["origin"] == "mcp" - # Mode is preview at every surface. - assert cli_payload["mode"] == daemon_payload["mode"] == mcp_payload["mode"] == "preview" - - def test_envelope_keys_identical_across_surfaces(self) -> None: - operation = _example_operation() - cli_keys = set(envelope_from_operation(operation, origin="cli", mode="preview").to_dict().keys()) - daemon_keys = set(envelope_from_operation(operation, origin="daemon", mode="preview").to_dict().keys()) - mcp_keys = set(envelope_from_operation(operation, origin="mcp", mode="preview").to_dict().keys()) - assert cli_keys == daemon_keys == mcp_keys == EXPECTED_ENVELOPE_KEYS - - -# --------------------------------------------------------------------------- -# MCP tool registration & invocation -# --------------------------------------------------------------------------- - - -class TestMaintenanceMCPTools: - """The MCP tools register and produce the shared envelope.""" - - def test_maintenance_tools_registered_when_capability_enabled(self) -> None: - from polylogue.mcp.declarations.models import MCPCapabilities - from polylogue.mcp.server import build_server - - server = build_server(capabilities=MCPCapabilities(maintenance=True)) - tools = server._tool_manager._tools - assert "maintenance" in tools - - def test_maintenance_tools_absent_from_read_only_server(self) -> None: - from polylogue.mcp.server import build_server - - server = build_server() - tools = server._tool_manager._tools - assert "maintenance" not in tools - - def test_maintenance_preview_returns_envelope(self) -> None: - from polylogue.mcp.declarations.models import MCPCapabilities - from polylogue.mcp.server import build_server - - operation = _example_operation() - server = build_server(capabilities=MCPCapabilities(maintenance=True)) - fn = server._tool_manager._tools["maintenance"].fn - - with _patched_preview(operation): - result = asyncio.run(fn(operation="preview")) - - payload = json.loads(result) - assert set(payload.keys()) == EXPECTED_ENVELOPE_KEYS - assert payload["origin"] == "mcp" - assert payload["mode"] == "preview" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _example_operation( - *, - failure_samples: BoundedFailureSamples | None = None, -) -> BackfillOperation: - return BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.PENDING, - progress=0.0, - started_at=None, - completed_at=None, - error=None, - affected_rows=42, - estimated_time_s=1.5, - results=[], - scope=MaintenanceScope(targets=("session_insights",)), - reason=None, - resume_cursor=None, - failure_samples=failure_samples if failure_samples is not None else BoundedFailureSamples(), - metrics={}, - ) - - -def _capture_cli_preview(operation: BackfillOperation, tmp_path: Path) -> dict[str, Any]: - archive = tmp_path / "archive" - render = tmp_path / "render" - archive.mkdir(parents=True, exist_ok=True) - render.mkdir(parents=True, exist_ok=True) - - runner = CliRunner() - - config_obj = Config(archive_root=archive, render_root=render, sources=[]) - - with ( - _patched_preview(operation), - patch("polylogue.cli.commands.maintenance._plan.archive_root", return_value=archive), - patch("polylogue.cli.commands.maintenance._plan.render_root", return_value=render), - ): - result = runner.invoke( - maintenance_group, - ["plan", "--target", "session_insights", "--output-format", "json"], - obj=config_obj, - ) - - assert result.exit_code == 0, result.output - payload: dict[str, Any] = json.loads(result.output) - return payload - - -class TestUnsupportedScopeDimensionsAreDeclared: - """A narrowing the selected targets cannot honor must be declared.""" - - @staticmethod - def _envelope_for(**filter_kwargs: object) -> MaintenanceScopePayload: - from polylogue.maintenance.scope import MaintenanceScopeFilter - - operation = BackfillOperation( - **{ - **_example_operation().__dict__, - "scope": MaintenanceScope( - targets=("session_insights",), - filter=MaintenanceScopeFilter(**filter_kwargs), # type: ignore[arg-type] - ), - } - ) - return envelope_from_operation(operation, origin="mcp", mode="execute").scope - - def test_a_dimension_the_run_cannot_honor_is_named(self) -> None: - """Anti-vacuity: return an empty tuple unconditionally and this goes red.""" - scope = self._envelope_for(origin="claude-ai-export") - - assert scope.unsupported_dimensions == ("origin",) - - def test_session_ids_is_honored_and_therefore_not_named(self) -> None: - """`session_ids` is the one dimension the run actually applies.""" - scope = self._envelope_for(session_ids=("claude-ai-export:abc",)) - - assert scope.unsupported_dimensions == () - - def test_session_ids_is_declared_when_any_selected_target_refuses_it(self) -> None: - operation = BackfillOperation( - **{ - **_example_operation().__dict__, - "targets": ("empty_sessions", "superseded_raw_snapshots"), - "scope": MaintenanceScope( - targets=("empty_sessions", "superseded_raw_snapshots"), - filter=MaintenanceScopeFilter(session_ids=("claude-ai-export:abc",)), - ), - } - ) - - envelope = envelope_from_operation(operation, origin="mcp", mode="execute") - - assert envelope.scope.unsupported_dimensions == ("session_ids",) - - def test_every_unhonored_dimension_is_reported_together(self) -> None: - scope = self._envelope_for( - origin="claude-ai-export", - failure_kind="parse_error", - parser_version="v3", - ) - - assert scope.unsupported_dimensions == ("origin", "failure_kind", "parser_version") - - def test_an_empty_filter_names_nothing(self) -> None: - scope = self._envelope_for() - - assert scope.unsupported_dimensions == () diff --git a/tests/unit/maintenance/test_failure_routing.py b/tests/unit/maintenance/test_failure_routing.py deleted file mode 100644 index 1d5ae02400..0000000000 --- a/tests/unit/maintenance/test_failure_routing.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for routing replay failures to the daemon raw-failure surface. - -Pins the acceptance criteria from issue #1198: - -* ``route_failure_sample`` writes a per-record JSONL line under - ``/.maintenance-state/failures.jsonl`` with the - originating ``operation_id``, target, kind, locator, message, and - routed timestamp; -* sensitive payloads (absolute paths embedded in messages and - locators) are redacted at write time; -* read/count helpers cap at :data:`MAINTENANCE_FAILURE_SAMPLE_LIMIT` - and never raise on a missing file; -* the replay executor calls ``route_failure_sample`` for every - ``FailureSample`` it appends to its in-memory bounded envelope, so - the on-disk surface matches the returned - :class:`BackfillOperation`. -""" - -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path - -import pytest - -from polylogue.config import Config -from polylogue.maintenance.failure_routing import ( - MAINTENANCE_FAILURE_SAMPLE_LIMIT, - MaintenanceFailureRecord, - clear_maintenance_failures, - count_maintenance_failures, - read_maintenance_failures, - resolve_maintenance_failures, - route_failure_sample, -) -from polylogue.maintenance.planner import FailureSample -from polylogue.maintenance.replay import execute_replay - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -# --------------------------------------------------------------------------- -# route_failure_sample contract -# --------------------------------------------------------------------------- - - -def test_route_failure_sample_persists_record(tmp_path: Path) -> None: - config = _make_config(tmp_path) - sample = FailureSample( - kind="RuntimeError", - locator="target:session_insights", - message="boom", - ) - record = route_failure_sample( - sample, - operation_id="op-abc", - archive_root=Path(config.archive_root), - target="session_insights", - now=datetime(2026, 1, 1, tzinfo=UTC), - ) - - assert record.operation_id == "op-abc" - assert record.target == "session_insights" - assert record.kind == "RuntimeError" - assert record.locator == "target:session_insights" - assert record.message == "boom" - assert record.routed_at == "2026-01-01T00:00:00+00:00" - - persisted = read_maintenance_failures(Path(config.archive_root)) - assert len(persisted) == 1 - assert persisted[0] == record - - -def test_route_failure_sample_infers_target_from_locator(tmp_path: Path) -> None: - config = _make_config(tmp_path) - sample = FailureSample( - kind="ValueError", - locator="target:session_insights:session:abc123", - message="oops", - ) - record = route_failure_sample( - sample, - operation_id="op-xyz", - archive_root=Path(config.archive_root), - ) - assert record.target == "session_insights" - - -def test_resolve_maintenance_failures_removes_matching_target_and_kind(tmp_path: Path) -> None: - config = _make_config(tmp_path) - root = Path(config.archive_root) - route_failure_sample( - FailureSample( - kind="UnsupportedReplayTargetError", - locator="target:message_type_backfill", - message="not wired", - ), - operation_id="op-old", - archive_root=root, - target="message_type_backfill", - ) - route_failure_sample( - FailureSample( - kind="RepairReportedFailure", - locator="target:message_type_backfill", - message="provider call failed", - ), - operation_id="op-real", - archive_root=root, - target="message_type_backfill", - ) - route_failure_sample( - FailureSample( - kind="UnsupportedReplayTargetError", - locator="target:orphaned_messages", - message="not wired", - ), - operation_id="op-other", - archive_root=root, - target="orphaned_messages", - ) - - removed = resolve_maintenance_failures( - root, - target="message_type_backfill", - kinds=("UnsupportedReplayTargetError",), - ) - - assert removed == 1 - remaining = read_maintenance_failures(root) - assert [(r.target, r.kind) for r in remaining] == [ - ("message_type_backfill", "RepairReportedFailure"), - ("orphaned_messages", "UnsupportedReplayTargetError"), - ] - - -def test_route_failure_sample_redacts_absolute_paths_in_message(tmp_path: Path) -> None: - config = _make_config(tmp_path) - sample = FailureSample( - kind="OSError", - locator="target:orphaned_messages", - message="Failed to read /home/operator/data/secret.json: permission denied", - ) - record = route_failure_sample( - sample, - operation_id="op-1", - archive_root=Path(config.archive_root), - ) - assert "/home/operator/data/secret.json" not in record.message - assert "[redacted]" in record.message - assert "permission denied" in record.message - - -def test_route_failure_sample_redacts_absolute_paths_in_locator(tmp_path: Path) -> None: - config = _make_config(tmp_path) - sample = FailureSample( - kind="ValueError", - locator="target:session_insights:session:c1:/home/operator/data.json", - message="bad", - ) - record = route_failure_sample( - sample, - operation_id="op-2", - archive_root=Path(config.archive_root), - ) - assert "/home/operator/data.json" not in record.locator - assert "[redacted]" in record.locator - - -def test_route_failure_sample_truncates_long_messages(tmp_path: Path) -> None: - config = _make_config(tmp_path) - long_message = "x" * 2000 - sample = FailureSample(kind="K", locator="target:t", message=long_message) - record = route_failure_sample( - sample, - operation_id="op-3", - archive_root=Path(config.archive_root), - ) - assert len(record.message) <= 500 - assert record.message.endswith("...") - - -def test_route_failure_sample_appends_multiple(tmp_path: Path) -> None: - config = _make_config(tmp_path) - for i in range(3): - route_failure_sample( - FailureSample(kind=f"K{i}", locator=f"target:t{i}", message=f"m{i}"), - operation_id="op-multi", - archive_root=Path(config.archive_root), - ) - records = read_maintenance_failures(Path(config.archive_root)) - assert len(records) == 3 - assert [r.kind for r in records] == ["K0", "K1", "K2"] - - -def test_route_failure_sample_never_writes_raw_paths_to_disk(tmp_path: Path) -> None: - config = _make_config(tmp_path) - sample = FailureSample( - kind="OSError", - locator="target:session_insights:session:c1:/home/op/secret.json", - message="Failed at /home/op/secret.json", - ) - route_failure_sample( - sample, - operation_id="op-secret", - archive_root=Path(config.archive_root), - ) - raw = (Path(config.archive_root) / ".maintenance-state" / "failures.jsonl").read_text() - assert "/home/op/secret.json" not in raw - - -# --------------------------------------------------------------------------- -# Reader helpers -# --------------------------------------------------------------------------- - - -def test_read_maintenance_failures_missing_file_returns_empty(tmp_path: Path) -> None: - config = _make_config(tmp_path) - assert read_maintenance_failures(Path(config.archive_root)) == [] - assert count_maintenance_failures(Path(config.archive_root)) == 0 - - -def test_read_maintenance_failures_caps_at_limit(tmp_path: Path) -> None: - config = _make_config(tmp_path) - for i in range(MAINTENANCE_FAILURE_SAMPLE_LIMIT + 10): - route_failure_sample( - FailureSample(kind="K", locator=f"target:t:{i}", message=str(i)), - operation_id="op-cap", - archive_root=Path(config.archive_root), - ) - records = read_maintenance_failures(Path(config.archive_root)) - assert len(records) == MAINTENANCE_FAILURE_SAMPLE_LIMIT - # Most recent entries are returned (tail of the file) - assert records[-1].message == str(MAINTENANCE_FAILURE_SAMPLE_LIMIT + 9) - - -def test_read_maintenance_failures_tolerates_unparseable_lines(tmp_path: Path) -> None: - config = _make_config(tmp_path) - state_dir = Path(config.archive_root) / ".maintenance-state" - state_dir.mkdir(parents=True, exist_ok=True) - path = state_dir / "failures.jsonl" - path.write_text( - '{"operation_id":"a","target":"t","kind":"K","locator":"target:t","message":"m","routed_at":"x"}\nnot json\n\n' - ) - records = read_maintenance_failures(Path(config.archive_root)) - assert len(records) == 1 - assert records[0].operation_id == "a" - - -def test_count_maintenance_failures_skips_blank_lines(tmp_path: Path) -> None: - config = _make_config(tmp_path) - state_dir = Path(config.archive_root) / ".maintenance-state" - state_dir.mkdir(parents=True, exist_ok=True) - path = state_dir / "failures.jsonl" - path.write_text('{"operation_id":"a"}\n\n{"operation_id":"b"}\n\n') - assert count_maintenance_failures(Path(config.archive_root)) == 2 - - -def test_clear_maintenance_failures_removes_file(tmp_path: Path) -> None: - config = _make_config(tmp_path) - route_failure_sample( - FailureSample(kind="K", locator="target:t", message="m"), - operation_id="op-clr", - archive_root=Path(config.archive_root), - ) - assert count_maintenance_failures(Path(config.archive_root)) == 1 - clear_maintenance_failures(Path(config.archive_root)) - assert count_maintenance_failures(Path(config.archive_root)) == 0 - - -def test_maintenance_failure_record_roundtrip(tmp_path: Path) -> None: - record = MaintenanceFailureRecord( - operation_id="op", - target="t", - kind="K", - locator="target:t", - message="m", - routed_at="2026-01-01T00:00:00+00:00", - ) - payload = record.to_dict() - parsed = MaintenanceFailureRecord.from_dict(payload) - assert parsed == record - - -# --------------------------------------------------------------------------- -# Replay executor wiring -# --------------------------------------------------------------------------- - - -def test_execute_replay_routes_unsupported_target_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """An unresolvable target appends a FailureSample that is also routed to disk.""" - config = _make_config(tmp_path) - - # Force a target to be resolved but missing from the dispatch table. - from polylogue.maintenance import replay as replay_mod - from polylogue.maintenance.models import MaintenanceCategory - from polylogue.maintenance.targets import MaintenanceTargetMode, MaintenanceTargetSpec - - class _FakeCatalog: - def resolve(self, names: tuple[str, ...]) -> tuple[MaintenanceTargetSpec, ...]: - return tuple( - MaintenanceTargetSpec( - name=name, - mode=MaintenanceTargetMode.REPAIR, - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - description="", - ) - for name in names - ) - - def resolve_or_default(self, names: tuple[str, ...]) -> tuple[MaintenanceTargetSpec, ...]: - return self.resolve(names) - - monkeypatch.setattr(replay_mod, "build_maintenance_target_catalog", lambda: _FakeCatalog()) - - operation = execute_replay( - config, - targets=["__missing__"], - operation_id="op-route", - persist_state=False, - ) - - assert operation.status.value == "failed" - assert len(operation.failure_samples.samples) == 1 - persisted = read_maintenance_failures(Path(config.archive_root)) - assert len(persisted) == 1 - assert persisted[0].operation_id == "op-route" - assert persisted[0].target == "__missing__" - assert persisted[0].kind == "UnsupportedReplayTargetError" - - -def test_execute_replay_routes_repair_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A repair fn that raises records a routed failure with the exception class.""" - config = _make_config(tmp_path) - - from polylogue.config import Config as ConfigT - from polylogue.storage import repair as repair_mod - from polylogue.storage.repair import RepairResult as RepairResultT - - def _bad_repair(_config: ConfigT, _dry_run: bool) -> RepairResultT: - raise RuntimeError("session insight rebuild failed: /tmp/path/session_42") - - monkeypatch.setitem(repair_mod.REPAIR_HANDLERS, "session_insights", _bad_repair) - - operation = execute_replay( - config, - targets=["session_insights"], - operation_id="op-raise", - persist_state=False, - ) - - assert operation.status.value == "failed" - persisted = read_maintenance_failures(Path(config.archive_root)) - assert len(persisted) == 1 - assert persisted[0].operation_id == "op-raise" - assert persisted[0].target == "session_insights" - assert persisted[0].kind == "RuntimeError" - # Path is redacted at routing time. - assert "/tmp/path/session_42" not in persisted[0].message - - -def test_execute_replay_routes_repair_reported_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """A repair fn that returns ``success=False`` is routed with the canonical kind.""" - config = _make_config(tmp_path) - - from polylogue.maintenance.models import MaintenanceCategory - from polylogue.storage import repair as repair_mod - from polylogue.storage.repair import RepairResult - - def _failing_repair(_config: Config, _dry_run: bool) -> RepairResult: - return RepairResult( - name="session_insights", - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=0, - success=False, - detail="insight repair could not converge", - ) - - monkeypatch.setitem(repair_mod.REPAIR_HANDLERS, "session_insights", _failing_repair) - - operation = execute_replay( - config, - targets=["session_insights"], - operation_id="op-soft", - persist_state=False, - ) - - assert operation.status.value == "failed" - persisted = read_maintenance_failures(Path(config.archive_root)) - assert len(persisted) == 1 - assert persisted[0].kind == "RepairReportedFailure" - assert "could not converge" in persisted[0].message diff --git a/tests/unit/maintenance/test_idempotency.py b/tests/unit/maintenance/test_idempotency.py deleted file mode 100644 index 7de55dabac..0000000000 --- a/tests/unit/maintenance/test_idempotency.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Idempotency tests for :mod:`polylogue.maintenance.replay` (#1147). - -These pin the convergence contract from the issue acceptance criteria: - -* running an operation twice on the same archive produces no further - changes after the first pass converges (the underlying repair - functions are convergent by construction; the replay loop adds the - multi-target guarantee that *no target is rebuilt twice and none is - skipped*); -* a per-target failure does not abort the rest of the operation and - surfaces as a bounded :class:`FailureSample`; -* an unsupported target is reported as a typed failure instead of - silently succeeding. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from pathlib import Path -from unittest.mock import patch - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.models import MaintenanceCategory -from polylogue.maintenance.planner import ( - MAX_FAILURE_SAMPLES, -) -from polylogue.maintenance.replay import ( - UnsupportedReplayTargetError, - execute_replay, - supported_replay_targets, -) -from polylogue.storage import repair as repair_mod -from polylogue.storage.repair import RepairResult - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -def _result(name: str, repaired: int) -> RepairResult: - return RepairResult( - name=name, - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=repaired, - success=True, - detail="", - ) - - -@pytest.fixture -def converging_dispatch() -> Iterator[dict[str, int]]: - """Stub dispatch that simulates convergence. - - First call to each target reports ``repaired_count=N`` (initial - rebuild). Subsequent calls return ``repaired_count=0`` because the - target is already converged. This matches the real repair-function - contract: re-running the session-insights repair on an already-consistent - archive returns 0. - """ - - converged: dict[str, int] = {} - - def stub(name: str, initial: int): # type: ignore[no-untyped-def] - def _run(_config: Config, _dry_run: bool) -> RepairResult: - attempt = converged.get(name, 0) - converged[name] = attempt + 1 - repaired = initial if attempt == 0 else 0 - return _result(name, repaired) - - return _run - - fake = { - "session_insights": stub("session_insights", 7), - } - with patch.object(repair_mod, "REPAIR_HANDLERS", fake): - yield converged - - -def test_repair_reported_failure_surfaces_as_failure_sample(tmp_path: Path) -> None: - config = _make_config(tmp_path) - - def reports_failure(_config: Config, _dry_run: bool) -> RepairResult: - return RepairResult( - name="empty_sessions", - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=0, - success=False, - detail="schema mismatch", - ) - - with patch.object(repair_mod, "REPAIR_HANDLERS", {"empty_sessions": reports_failure}): - op = execute_replay( - config, - targets=("empty_sessions",), - operation_id="op-soft-fail", - ) - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "RepairReportedFailure" - assert "schema mismatch" in op.failure_samples.samples[0].message - - -def test_replay_refuses_offline_repair_while_daemon_runs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - config = _make_config(tmp_path) - monkeypatch.setattr("polylogue.maintenance.offline_guard.running_daemon_pid", lambda _config: 1234) - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-live-daemon", - ) - - assert op.status is OperationStatus.FAILED - assert op.affected_rows == 0 - assert op.results[0]["name"] == "session_insights" - assert op.failure_samples.samples[0].kind == "OfflineMaintenanceBlocked" - assert "polylogued PID 1234 is running" in op.failure_samples.samples[0].message - - -def test_unwired_target_is_typed_failure_not_silent(tmp_path: Path) -> None: - config = _make_config(tmp_path) - - with patch.object(repair_mod, "REPAIR_HANDLERS", {}): - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-unsupported", - ) - - assert op.status is OperationStatus.FAILED - sample = op.failure_samples.samples[0] - assert sample.kind == UnsupportedReplayTargetError.__name__ - assert sample.locator == "target:session_insights" - - -def test_supported_targets_cover_ac_required_set() -> None: - """Replay supports the durable maintenance targets advertised in the catalog.""" - supported = set(supported_replay_targets()) - required = { - "session_insights", - } - assert required.issubset(supported) - - -def test_failure_samples_remain_bounded(tmp_path: Path) -> None: - """Even a pathologically failing run cannot exceed the planner's - bounded sample envelope.""" - - config = _make_config(tmp_path) - - def always_raises(_config: Config, _dry_run: bool) -> RepairResult: - raise RuntimeError("boom") - - # Build a synthetic dispatch with N > MAX_FAILURE_SAMPLES targets, - # all failing. Reuse the existing target names because the catalog - # is the source of resolution; targets repeated in the input are - # deduplicated upstream, so we instead drive the loop directly - # through the public dispatch by faking the catalog resolution. - target_names = tuple(f"t{i}" for i in range(MAX_FAILURE_SAMPLES + 5)) - - class _FakeSpec: - def __init__(self, name: str) -> None: - self.name = name - self.replayable = True - self.non_replayable_reason = "" - - fake_specs = tuple(_FakeSpec(name) for name in target_names) - - class _FakeCatalog: - def resolve(self, _names: tuple[str, ...]) -> tuple[_FakeSpec, ...]: - return fake_specs - - def resolve_or_default(self, _names: tuple[str, ...]) -> tuple[_FakeSpec, ...]: - return fake_specs - - fake_dispatch = dict.fromkeys(target_names, always_raises) - with ( - patch( - "polylogue.maintenance.replay.build_maintenance_target_catalog", - return_value=_FakeCatalog(), - ), - patch.object(repair_mod, "REPAIR_HANDLERS", fake_dispatch), - ): - op = execute_replay( - config, - targets=target_names, - operation_id="op-flood", - persist_state=False, - ) - - assert op.status is OperationStatus.FAILED - assert len(op.failure_samples.samples) == MAX_FAILURE_SAMPLES - assert op.failure_samples.truncated is True diff --git a/tests/unit/maintenance/test_planner_contract.py b/tests/unit/maintenance/test_planner_contract.py deleted file mode 100644 index 3a77757666..0000000000 --- a/tests/unit/maintenance/test_planner_contract.py +++ /dev/null @@ -1,445 +0,0 @@ -"""Contract tests for the typed maintenance planner (issue #1144). - -These exercise the typed shape of ``BackfillOperation`` and its -sub-types — the rest of the maintenance cluster (resume/idempotency, -operation-envelope wiring, embedding/cost backfills) builds on top of -this contract and depends on the shape staying stable. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import cast - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.invalidation import InvalidationReason -from polylogue.maintenance.models import DerivedModelStatus -from polylogue.maintenance.planner import ( - MAX_FAILURE_SAMPLES, - BackfillKind, - BackfillOperation, - BoundedFailureSamples, - FailureSample, - MaintenanceScope, - _derive_invalidation_reason, - execute_backfill, - preview_backfill, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter -from polylogue.maintenance.targets import build_maintenance_target_catalog -from tests.infra.storage_records import DbFactory, db_setup - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -def _caller_archive_workspace(workspace_env: dict[str, Path]) -> dict[str, Path]: - """Initialize a caller archive distinct from the ambient fixture root.""" - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - caller_archive_root = workspace_env["data_root"] / "caller-archive" - with ArchiveStore(caller_archive_root): - pass - return {**workspace_env, "archive_root": caller_archive_root} - - -class TestBackfillKindCoverage: - """Every typed-taxonomy kind from #1144 must be representable.""" - - @pytest.mark.parametrize( - "kind", - [ - BackfillKind.ARCHIVE_SUBSET, - BackfillKind.DERIVED_REBUILD, - BackfillKind.INDEX_REPAIR, - BackfillKind.SEMANTIC_REMATERIALIZE, - BackfillKind.CONFIG_DRIVEN, - ], - ) - def test_kind_roundtrips_through_to_dict(self, kind: BackfillKind) -> None: - op = BackfillOperation( - operation_id="op-1", - kind=kind, - targets=("session_insights",), - ) - payload = op.to_dict() - assert payload["kind"] == kind.value - - @pytest.mark.parametrize( - ("stored_kind", "expected"), - [ - ("backfill", BackfillKind.DERIVED_REBUILD), - ("rebuild", BackfillKind.DERIVED_REBUILD), - ("reindex", BackfillKind.INDEX_REPAIR), - ("reset", BackfillKind.CONFIG_DRIVEN), - ], - ) - def test_retired_stored_kind_values_rehydrate_to_typed_kind(self, stored_kind: str, expected: BackfillKind) -> None: - op = BackfillOperation.from_dict( - { - "operation_id": "op-1", - "kind": stored_kind, - "targets": ["session_insights"], - } - ) - assert op.kind is expected - - -class TestInvalidationReasonCoverage: - """Every InvalidationReason value from #1144 must be representable - and must survive the BackfillOperation -> to_dict roundtrip.""" - - @pytest.mark.parametrize( - "reason", - [ - InvalidationReason.MISSING, - InvalidationReason.STALE_MATERIALIZER_VERSION, - InvalidationReason.SOURCE_CHANGED, - InvalidationReason.PARSER_OR_SCHEMA_CHANGED, - InvalidationReason.CONFIG_OR_MODEL_SNAPSHOT_CHANGED, - InvalidationReason.UNKNOWN, - ], - ) - def test_reason_roundtrips_through_to_dict(self, reason: InvalidationReason) -> None: - op = BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - reason=reason, - ) - assert op.to_dict()["reason"] == reason.value - - -class TestMaintenanceScope: - def test_scope_default_filter_is_empty(self) -> None: - from polylogue.maintenance.scope import MaintenanceScopeFilter - - scope = MaintenanceScope(targets=("a", "b")) - assert scope.filter == MaintenanceScopeFilter() - assert scope.filter.is_empty() - payload = scope.to_dict() - assert payload["targets"] == ["a", "b"] - filter_payload = cast(dict[str, object], payload["filter"]) - # Every typed scope dimension is present with a None default. - assert filter_payload["session_ids"] is None - assert filter_payload["origin"] is None - - def test_scope_filter_roundtrips(self) -> None: - from polylogue.maintenance.scope import MaintenanceScopeFilter - - scope = MaintenanceScope( - targets=("session_insights",), - filter=MaintenanceScopeFilter( - session_ids=("c1", "c2"), - origin="claude-code-session", - ), - ) - payload = scope.to_dict() - filter_payload = cast(dict[str, object], payload["filter"]) - assert filter_payload["session_ids"] == ["c1", "c2"] - assert filter_payload["origin"] == "claude-code-session" - # to_dict / from_dict round-trips the scope back to itself. - scope_again = MaintenanceScope.from_dict(cast(dict[str, object], payload)) - assert scope_again == scope - - def test_operation_synthesizes_scope_from_targets(self) -> None: - op = BackfillOperation( - operation_id="op-x", - kind=BackfillKind.DERIVED_REBUILD, - targets=("a", "b"), - ) - assert op.scope is not None - assert op.scope.targets == ("a", "b") - - def test_operation_trusts_explicit_scope(self) -> None: - scope = MaintenanceScope(targets=("only_scope",)) - op = BackfillOperation( - operation_id="op-x", - kind=BackfillKind.DERIVED_REBUILD, - targets=("ignored",), - scope=scope, - ) - # scope wins; .targets is realigned for caller convenience. - assert op.targets == ("only_scope",) - - -class TestBoundedFailureSamples: - def test_under_limit_passes_through(self) -> None: - samples = [FailureSample(kind="X", locator=str(i), message="m") for i in range(3)] - envelope = BoundedFailureSamples.from_samples(samples) - assert len(envelope.samples) == 3 - assert envelope.truncated is False - - def test_over_limit_truncates_and_flags(self) -> None: - samples = [FailureSample(kind="X", locator=str(i), message="m") for i in range(MAX_FAILURE_SAMPLES + 5)] - envelope = BoundedFailureSamples.from_samples(samples) - assert len(envelope.samples) == MAX_FAILURE_SAMPLES - assert envelope.truncated is True - - def test_envelope_to_dict_includes_truncated_flag(self) -> None: - envelope = BoundedFailureSamples.from_samples([FailureSample(kind="K", locator="row-1", message="boom")]) - payload = envelope.to_dict() - assert payload["truncated"] is False - samples_payload = cast(list[dict[str, object]], payload["samples"]) - assert samples_payload == [{"kind": "K", "locator": "row-1", "message": "boom"}] - - -class TestResumeCursorRoundtrip: - def test_cursor_survives_to_dict(self) -> None: - op = BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - resume_cursor="rowid:12345", - ) - assert op.to_dict()["resume_cursor"] == "rowid:12345" - - def test_missing_cursor_is_none(self) -> None: - op = BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - ) - assert op.to_dict()["resume_cursor"] is None - - -class TestMetricsRoundtrip: - def test_metrics_are_independent_dict(self) -> None: - metrics = {"rows_per_s": 123.4, "passes": 2.0} - op = BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - metrics=metrics, - ) - payload = op.to_dict() - metrics_payload = cast(dict[str, float], payload["metrics"]) - assert metrics_payload == metrics - # Mutating the payload must not affect the source dict. - metrics_payload["rows_per_s"] = 0.0 - assert op.metrics["rows_per_s"] == 123.4 - - -class TestInvalidationKeysOnTargets: - """The #1144 AC requires invalidation_keys on the listed targets.""" - - def test_target_specs_have_required_invalidation_keys(self) -> None: - catalog = build_maintenance_target_catalog() - by_name = catalog.by_name() - - assert "session.profile" in by_name["session_insights"].invalidation_keys - - def test_invalidation_keys_surface_through_to_dict(self) -> None: - spec = build_maintenance_target_catalog().by_name()["session_insights"] - payload = spec.to_dict() - invalidation_keys = cast(list[str], payload["invalidation_keys"]) - assert "session.profile" in invalidation_keys - - -class TestDeriveInvalidationReason: - def test_ready_status_yields_no_reason(self) -> None: - status = DerivedModelStatus(name="x", ready=True, detail="ok") - assert _derive_invalidation_reason(status) is None - - def test_missing_materialized_documents_yields_missing(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="empty", - source_documents=10, - materialized_documents=0, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.MISSING - - def test_explicit_invalidated_reason_wins(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="stale", - source_documents=5, - materialized_documents=5, - invalidated_reason=InvalidationReason.CONFIG_OR_MODEL_SNAPSHOT_CHANGED, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.CONFIG_OR_MODEL_SNAPSHOT_CHANGED - - def test_version_mismatch_yields_stale_materializer_version(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="version skew", - source_documents=5, - materialized_documents=5, - matches_version=False, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.STALE_MATERIALIZER_VERSION - - def test_stale_rows_yields_source_changed(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="some stale rows", - source_documents=5, - materialized_documents=5, - stale_rows=2, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.SOURCE_CHANGED - - def test_missing_provenance_yields_parser_or_schema_changed(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="provenance gone", - source_documents=5, - materialized_documents=5, - missing_provenance_rows=3, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.PARSER_OR_SCHEMA_CHANGED - - def test_unclassified_unready_falls_back_to_unknown(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="no signal", - source_documents=5, - materialized_documents=5, - ) - assert _derive_invalidation_reason(status) is InvalidationReason.UNKNOWN - - def test_non_status_input_is_ignored(self) -> None: - assert _derive_invalidation_reason("not a status") is None - - -class TestEmptyTargetsFastFail: - def test_preview_with_no_resolvable_targets_returns_failed(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - op = preview_backfill(config, targets=("does-not-exist",)) - assert op.status is OperationStatus.FAILED - assert op.targets == () - assert op.scope is not None - assert op.scope.targets == () - - def test_execute_with_no_resolvable_targets_returns_failed(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - op = execute_backfill(config, targets=("does-not-exist",)) - assert op.status is OperationStatus.FAILED - assert op.targets == () - - -class TestExecutionScopeRefusals: - def test_preview_refuses_an_unsupported_target_without_advertising_rows( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - from polylogue.storage import repair as repair_module - - monkeypatch.setattr( - repair_module, - "preview_counts_from_archive_debt", - lambda _statuses: {"superseded_raw_snapshots": 100}, - ) - - operation = preview_backfill( - _make_config(tmp_path), - targets=("superseded_raw_snapshots",), - scope_filter=MaintenanceScopeFilter(session_ids=("session-1",)), - ) - - assert operation.status is OperationStatus.FAILED - assert operation.affected_rows == 0 - assert operation.failure_samples.samples[0].kind == "UnsupportedScopeDimension" - - def test_execute_reports_typed_failure_for_each_rejected_target(self, tmp_path: Path) -> None: - operation = execute_backfill( - _make_config(tmp_path), - targets=("superseded_raw_snapshots",), - scope_filter=MaintenanceScopeFilter(session_ids=("session-1",)), - ) - - assert operation.status is OperationStatus.FAILED - assert operation.failure_samples.samples == ( - FailureSample( - kind="UnsupportedScopeDimension", - locator="target:superseded_raw_snapshots", - message="Unsupported scope dimensions for target 'superseded_raw_snapshots': session_ids", - ), - ) - - -class TestConfigThreading: - """Regression: the planner must resolve the archive `index.db` from the - caller's ``config`` (archive_root/db_path), not rely on ambient defaults - (the original scaffold did the latter and broke multi-archive tests). - """ - - def test_preview_reads_the_callers_seeded_archive(self, workspace_env: dict[str, Path]) -> None: - """Planner debt comes from the supplied archive, not ambient paths.""" - caller_workspace = _caller_archive_workspace(workspace_env) - index_db = db_setup(caller_workspace) - factory = DbFactory(index_db) - factory.create_session(id="planner-config") - # A message-less session with no raw artifact is "no evidence either - # way" to the empty_sessions debt classifier and never counts as - # debt (polylogue-9rdky) -- give it a phantom raw the classifier - # positively refuses so this fixture produces real debt. - factory.mark_as_phantom_debris("planner-config") - config = Config( - archive_root=caller_workspace["archive_root"], - render_root=workspace_env["data_root"] / "render", - sources=[], - db_path=index_db, - ) - - preview = preview_backfill(config, targets=("empty_sessions",)) - - assert preview.targets == ("empty_sessions",) - assert preview.affected_rows == 1 - assert preview.results - - def test_execute_dry_run_reads_the_callers_seeded_archive(self, workspace_env: dict[str, Path]) -> None: - caller_workspace = _caller_archive_workspace(workspace_env) - index_db = db_setup(caller_workspace) - factory = DbFactory(index_db) - factory.create_session(id="planner-execute") - # See test_preview_reads_the_callers_seeded_archive above. - factory.mark_as_phantom_debris("planner-execute") - config = Config( - archive_root=caller_workspace["archive_root"], - render_root=workspace_env["data_root"] / "render", - sources=[], - db_path=index_db, - ) - - operation = execute_backfill(config, targets=("empty_sessions",), dry_run=True) - - assert operation.status is OperationStatus.COMPLETED - assert operation.affected_rows == 1 - assert operation.results[0]["name"] == "empty_sessions" - - -class TestDerivedModelStatusInvalidatedReason: - def test_status_to_dict_includes_invalidated_reason(self) -> None: - status = DerivedModelStatus( - name="x", - ready=False, - detail="d", - invalidated_reason=InvalidationReason.MISSING, - ) - payload = status.to_dict() - assert payload["invalidated_reason"] == "missing" - - def test_status_to_dict_omits_reason_when_none(self) -> None: - status = DerivedModelStatus(name="x", ready=True, detail="ok") - payload = status.to_dict() - assert payload["invalidated_reason"] is None diff --git a/tests/unit/maintenance/test_planner_filter_narrowing.py b/tests/unit/maintenance/test_planner_filter_narrowing.py deleted file mode 100644 index 76ef88d133..0000000000 --- a/tests/unit/maintenance/test_planner_filter_narrowing.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Planner narrows ``affected_rows`` when the scope filter narrows the scope (#1303). - -The planner's contract with the typed -:class:`MaintenanceScopeFilter` is that a narrower filter must -produce a narrower preview — the operator must never see a single- -session plan advertise the full archive's debt as its work. - -Pins: - -* a ``session_ids`` filter clamps ``affected_rows`` to the size - of the filter set; -* the filter is threaded onto the returned :class:`MaintenanceScope` - so the envelope echoes it back unchanged; -* an empty filter does not narrow the preview; -* a filter with zero session ids cannot mask a broader debt by - accident (the underlying debt count is preserved when there is no - session-id narrowing). -""" - -from __future__ import annotations - -import sqlite3 -from contextlib import closing -from pathlib import Path - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.planner import preview_backfill -from polylogue.maintenance.scope import MaintenanceScopeFilter -from tests.infra.storage_records import DbFactory, db_setup - - -def _seeded_config(workspace_env: dict[str, Path], *, sessions: int = 3) -> Config: - index_db = db_setup(workspace_env) - factory = DbFactory(index_db) - for index in range(sessions): - native_id = f"empty-{index}" - factory.create_session(id=native_id) - # A message-less session with no raw artifact at all (raw_id IS - # NULL) is "no evidence either way" to the empty_sessions debt - # classifier and is never counted as debt -- give each one a - # phantom raw artifact the classifier positively refuses, so this - # fixture actually produces real archive debt for the tests below - # to narrow (polylogue-9rdky). - factory.mark_as_phantom_debris(native_id) - return Config( - archive_root=workspace_env["archive_root"], - render_root=workspace_env["data_root"] / "render", - sources=[], - db_path=index_db, - ) - - -def _debris_session_ids(config: Config) -> tuple[str, ...]: - with closing(sqlite3.connect(config.db_path)) as conn: - return tuple(str(row[0]) for row in conn.execute("SELECT session_id FROM sessions ORDER BY session_id")) - - -class TestPlannerNarrowsBySessionIds: - """A ``session_ids`` filter previews exactly the requested sessions.""" - - def test_single_session_filter_counts_that_session(self, workspace_env: dict[str, Path]) -> None: - config = _seeded_config(workspace_env) - requested = _debris_session_ids(config)[:1] - narrow = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(session_ids=requested), - ) - assert narrow.affected_rows == 1 - # And the filter is echoed back on the returned scope so the - # envelope can serialize it. - assert narrow.scope is not None - assert narrow.scope.filter.session_ids == requested - - def test_multi_session_filter_counts_the_requested_sessions(self, workspace_env: dict[str, Path]) -> None: - config = _seeded_config(workspace_env) - requested = _debris_session_ids(config) - assert len(requested) == 3 - narrow = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(session_ids=requested), - ) - assert narrow.affected_rows == 3 - assert narrow.scope is not None - assert narrow.scope.filter.session_ids == requested - - def test_session_filter_does_not_inflate_when_debt_is_smaller(self, workspace_env: dict[str, Path]) -> None: - """A filter naming 100 ids cannot inflate a 2-row debt to 100.""" - config = _seeded_config(workspace_env, sessions=2) - requested = _debris_session_ids(config) + tuple(f"c{i}" for i in range(100)) - narrow = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(session_ids=requested), - ) - assert narrow.affected_rows == 2 - - def test_healthy_requested_sessions_preview_no_rows(self, workspace_env: dict[str, Path]) -> None: - """Rows are counted over the requested sessions, not clamped to the request size. - - Anti-vacuity: restoring the ``min(total_rows, len(session_ids))`` - clamp makes this red -- three unrelated debris sessions plus a - one-session request would preview 1 affected row while - ``repair_empty_sessions`` scoped to that session deletes none. - """ - config = _seeded_config(workspace_env) - narrow = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(session_ids=("test:healthy-and-unrelated",)), - ) - assert narrow.affected_rows == 0 - assert narrow.estimated_time_s == 0.0 - - -class TestPlannerRefusesUnsupportedFilters: - """Filters a target cannot apply produce no executable preview rows.""" - - @pytest.mark.parametrize( - "scope_filter", - [ - MaintenanceScopeFilter(origin="claude-code-session"), - MaintenanceScopeFilter(source_family="claude-code-session"), - MaintenanceScopeFilter(failure_kind="ValidationError"), - MaintenanceScopeFilter(parser_version="v3"), - ], - ) - def test_unsupported_filters_refuse_the_target_without_rows( - self, workspace_env: dict[str, Path], scope_filter: MaintenanceScopeFilter - ) -> None: - """Anti-vacuity: counting full debt for a refused target goes red.""" - config = _seeded_config(workspace_env) - broad = preview_backfill(config, targets=("empty_sessions",)) - scoped = preview_backfill(config, targets=("empty_sessions",), scope_filter=scope_filter) - assert broad.affected_rows == 3 - assert scoped.status is OperationStatus.FAILED - assert scoped.affected_rows == 0 - assert scoped.scope is not None - assert scoped.scope.filter == scope_filter - assert scoped.failure_samples.samples[0].kind == "UnsupportedScopeDimension" - - def test_refused_preview_carries_an_error_message(self, workspace_env: dict[str, Path]) -> None: - """A refused preview names its refusal in ``error``. - - Anti-vacuity: dropping the ``error=`` argument from - ``preview_backfill``'s refusal receipt makes this red -- the plain - CLI reads ``result.error`` and would print nothing but - "Affected: 0 rows" for a permanently refused request. - """ - config = _seeded_config(workspace_env) - scoped = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(origin="claude-code-session"), - ) - assert scoped.status is OperationStatus.FAILED - assert scoped.error is not None - assert "origin" in scoped.error - - -class TestOmittedSessionFilterIsNotAScope: - """An omitted repeatable CLI option is no narrowing at all.""" - - def test_empty_session_id_tuple_normalizes_to_none(self, workspace_env: dict[str, Path]) -> None: - """``--session-id`` omitted must not refuse a target that ignores it. - - Anti-vacuity: restoring ``_coerce_session_ids``'s ``tuple(...)`` - without the ``or None`` makes this red -- Click hands ``()`` for an - omitted repeatable option, and ``superseded_raw_snapshots`` honors no - scope dimension, so the default plan would be refused. - """ - config = _seeded_config(workspace_env) - scope_filter = MaintenanceScopeFilter.from_surface_args(session_ids=()) - assert scope_filter.session_ids is None - assert scope_filter.is_empty() - plan = preview_backfill( - config, - targets=("superseded_raw_snapshots",), - scope_filter=scope_filter, - ) - assert plan.status is OperationStatus.PENDING - assert plan.failure_samples.samples == () - - -class TestPlannerWithEmptyFilter: - """An empty / default filter must not narrow the preview.""" - - def test_default_filter_preserves_full_debt(self, workspace_env: dict[str, Path]) -> None: - config = _seeded_config(workspace_env) - broad = preview_backfill(config, targets=("empty_sessions",)) - explicit_empty = preview_backfill( - config, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(), - ) - assert broad.affected_rows == 3 - assert explicit_empty.affected_rows == 3 - # Both ought to attach an empty filter onto the scope. - assert broad.scope is not None and broad.scope.filter.is_empty() - assert explicit_empty.scope is not None and explicit_empty.scope.filter.is_empty() diff --git a/tests/unit/maintenance/test_preview.py b/tests/unit/maintenance/test_preview.py deleted file mode 100644 index d82af5e897..0000000000 --- a/tests/unit/maintenance/test_preview.py +++ /dev/null @@ -1,435 +0,0 @@ -"""Staleness inventory preview surface contracts. - -Validates ``polylogue.maintenance.preview.staleness_inventory`` and -``polylogue ops maintenance preview`` against the acceptance criteria of -issue #1145: - -* multiple :class:`InvalidationReason` values exercised (not just - ``VERSION_MISMATCH`` aka ``materializer_version``); -* zero-row models still emit explicit rows (not absence); -* preview is read-only — no DB mutations observed via SQLite write hook; -* CLI subcommand renders the inventory in plain and JSON modes; -* per-model fractions report stale / source. -""" - -from __future__ import annotations - -import json -import sqlite3 -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from polylogue.cli.commands.maintenance import maintenance_group -from polylogue.maintenance.models import DerivedModelStatus -from polylogue.maintenance.preview import ( - ALL_SCOPES, - InvalidationReason, - StalenessInventory, - StalenessItem, - staleness_inventory, -) -from tests.infra.storage_records import SessionBuilder, db_setup - -# --------------------------------------------------------------------------- -# Pure projection tests (no DB) -# --------------------------------------------------------------------------- - - -def test_preview_scopes_match_preview_module() -> None: - """The CLI's hardcoded --help choices (polylogue-sod7) must not drift. - - _preview.py hardcodes _ALL_SCOPES instead of importing ALL_SCOPES from - polylogue.maintenance.preview, so that module's heavier - storage.derived/storage.repair import chain isn't paid on the `--help` - path. This test is the drift guard for that duplication. - """ - from polylogue.cli.commands.maintenance._preview import _ALL_SCOPES - - assert _ALL_SCOPES == ALL_SCOPES - - -def test_staleness_item_fraction_clamps_and_handles_zero_source() -> None: - zero = StalenessItem( - model="x", - scope="derived", - reason=InvalidationReason.STALE, - count=5, - source_total=0, - materialized_total=0, - detail="", - ) - assert zero.fraction == 0.0 - - partial = StalenessItem( - model="x", - scope="derived", - reason=InvalidationReason.STALE, - count=3, - source_total=12, - materialized_total=12, - detail="", - ) - assert partial.fraction == pytest.approx(0.25) - - over = StalenessItem( - model="x", - scope="derived", - reason=InvalidationReason.STALE, - count=999, - source_total=10, - materialized_total=10, - detail="", - ) - assert over.fraction == 1.0 - - -def test_inventory_to_dict_round_trips_items_and_totals() -> None: - inv = StalenessInventory( - captured_at="2026-05-17T00:00:00+00:00", - db_path=":memory:", - scopes=ALL_SCOPES, - items=( - StalenessItem( - model="messages_fts", - scope="derived", - reason=InvalidationReason.MISSING, - count=2, - source_total=10, - materialized_total=8, - detail="2 pending", - ), - StalenessItem( - model="messages_fts", - scope="derived", - reason=InvalidationReason.STALE, - count=1, - source_total=10, - materialized_total=8, - detail="1 stale", - ), - ), - ) - payload = json.loads(json.dumps(inv.to_dict())) - - assert payload["total_stale"] == 3 - assert len(payload["items"]) == 2 - assert payload["items"][0]["reason"] == "missing" - assert payload["items"][0]["fraction"] == pytest.approx(0.2, abs=1e-6) - - -def test_inventory_by_model_groups_items() -> None: - items = ( - StalenessItem( - model="m1", - scope="derived", - reason=InvalidationReason.MISSING, - count=1, - source_total=1, - materialized_total=0, - detail="", - ), - StalenessItem( - model="m1", - scope="derived", - reason=InvalidationReason.STALE, - count=0, - source_total=1, - materialized_total=1, - detail="", - ), - StalenessItem( - model="m2", - scope="retrieval", - reason=InvalidationReason.ORPHAN, - count=2, - source_total=5, - materialized_total=5, - detail="", - ), - ) - inv = StalenessInventory( - captured_at="t", - db_path=":memory:", - scopes=ALL_SCOPES, - items=items, - ) - - grouped = inv.by_model() - assert set(grouped.keys()) == {"m1", "m2"} - assert len(grouped["m1"]) == 2 - assert len(grouped["m2"]) == 1 - - -# --------------------------------------------------------------------------- -# Read-only invariant -# --------------------------------------------------------------------------- - - -def test_staleness_inventory_performs_no_writes(workspace_env: dict[str, Path]) -> None: - """Preview must not mutate the database. - - Uses SQLite's authorizer hook to count write attempts directly on the - backing connection while ``staleness_inventory`` runs. - """ - - db_path = db_setup(workspace_env) - # Seed a small archive so we have a real DB to preview against. - SessionBuilder(db_path, "preview-1").provider("chatgpt").title("seed").add_message( - role="user", text="hello" - ).add_message(role="assistant", text="world").save() - SessionBuilder(db_path, "preview-2").provider("claude-code").title("seed2").add_message( - role="user", text="one" - ).save() - - write_actions = { - sqlite3.SQLITE_INSERT, - sqlite3.SQLITE_UPDATE, - sqlite3.SQLITE_DELETE, - sqlite3.SQLITE_CREATE_TABLE, - sqlite3.SQLITE_DROP_TABLE, - sqlite3.SQLITE_ALTER_TABLE, - sqlite3.SQLITE_CREATE_INDEX, - sqlite3.SQLITE_DROP_INDEX, - } - write_attempts: list[tuple[int, str | None, str | None]] = [] - - # Open a separate read-only connection with an authorizer; we cannot - # install an authorizer on the cached connection used by - # ``staleness_inventory`` itself, so we take a row-count snapshot - # before/after as the primary invariant and the authorizer guards a - # parallel sanity check on our own connection. - sanity = sqlite3.connect(str(db_path)) - - def _authorizer( - action: int, - arg1: str | None, - arg2: str | None, - db_name: str | None, - trigger: str | None, - ) -> int: - if action in write_actions: - write_attempts.append((action, arg1, arg2)) - return sqlite3.SQLITE_OK - - sanity.set_authorizer(_authorizer) - # Probing read on the authorized connection just to confirm the hook works. - # ``db_setup`` returns the ``index.db``; the session tree - # lives in ``sessions`` (no legacy ``sessions`` table). - sanity.execute("SELECT COUNT(*) FROM sessions").fetchone() - sanity.close() - assert write_attempts == [] - - # Row-count snapshot across every table — preview must not change - # any of them. - def _snapshot() -> dict[str, int]: - with sqlite3.connect(str(db_path)) as conn: - rows = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" - ).fetchall() - counts: dict[str, int] = {} - for (name,) in rows: - try: - counts[name] = int(conn.execute(f"SELECT COUNT(*) FROM '{name}'").fetchone()[0]) - except sqlite3.DatabaseError: - # FTS shadow tables can reject SELECT COUNT — skip. - continue - return counts - - before = _snapshot() - inv = staleness_inventory(db_path) - after = _snapshot() - - assert before == after, "preview mutated row counts" - assert isinstance(inv, StalenessInventory) - # The active archive is the archive store; the inventory reads - # the session/message/block tree from ``index.db`` (not the legacy - # single-file ``index.db``). - assert inv.db_path.endswith("index.db") - - -# --------------------------------------------------------------------------- -# AC: zero-row models emit explicit rows, multiple reasons exercised -# --------------------------------------------------------------------------- - - -def test_inventory_emits_zero_rows_for_clean_models(workspace_env: dict[str, Path]) -> None: - db_path = db_setup(workspace_env) - SessionBuilder(db_path, "clean-1").provider("chatgpt").title("clean").add_message(role="user", text="hi").save() - - inv = staleness_inventory(db_path) - - # Every model that emitted rows must emit MISSING/STALE/ORPHAN - # explicitly (count=0 is fine), so consumers don't have to guess - # "absent vs zero". - grouped = inv.by_model() - assert grouped, "no models inventoried for a non-empty archive" - - for model, items in grouped.items(): - if model == "empty_sessions": - # Archive-cleanup scopes report a single orphan_archive_row item. - assert {item.reason for item in items} == {InvalidationReason.ORPHAN_ARCHIVE_ROW} - continue - reasons = {item.reason for item in items} - assert { - InvalidationReason.MISSING, - InvalidationReason.STALE, - InvalidationReason.ORPHAN, - }.issubset(reasons), f"model {model} missing baseline reasons: {reasons}" - - -def test_inventory_exercises_multiple_invalidation_reasons(monkeypatch: pytest.MonkeyPatch) -> None: - """AC: not all stale rows can be attributed to materializer_version. - - Stubs the derived-status collector with synthetic statuses that - exercise four different reasons across two models, plus injects - archive-cleanup orphans via a temporary DB. - """ - - from polylogue.maintenance import preview as preview_mod - - def _fake_statuses(_conn: sqlite3.Connection, *, verify_full: bool = True) -> dict[str, DerivedModelStatus]: - return { - "messages_fts": DerivedModelStatus( - name="messages_fts", - ready=False, - detail="", - source_documents=100, - materialized_documents=80, - source_rows=100, - materialized_rows=80, - pending_rows=20, - stale_rows=5, - orphan_rows=2, - missing_provenance_rows=1, - materializer_version=4, - matches_version=True, - ), - "transcript_embeddings": DerivedModelStatus( - name="transcript_embeddings", - ready=False, - detail="", - source_documents=50, - materialized_documents=10, - materialized_rows=10, - pending_documents=40, - stale_rows=0, - orphan_rows=0, - materializer_version=2, - matches_version=False, - ), - # Models we don't inventory in this test — included to verify - # filtering. - "noise_model": DerivedModelStatus( - name="noise_model", - ready=True, - detail="", - ), - } - - monkeypatch.setattr(preview_mod, "_archive_cleanup_items", lambda _c: []) - from polylogue.storage.derived import derived_status as derived_status_mod - - monkeypatch.setattr( - derived_status_mod, - "collect_derived_model_statuses_sync", - _fake_statuses, - ) - - # Use an in-memory SQLite connection — connection_context happily - # accepts a pre-opened connection and returns it as-is. - conn = sqlite3.connect(":memory:") - inv = staleness_inventory(conn, scopes=("derived", "retrieval")) - - by_model = inv.by_model() - fts_reasons = {item.reason for item in by_model["messages_fts"]} - embed_reasons = {item.reason for item in by_model["transcript_embeddings"]} - - # Four distinct reasons across the suite. - all_reasons = fts_reasons | embed_reasons - assert { - InvalidationReason.MISSING, - InvalidationReason.STALE, - InvalidationReason.ORPHAN, - InvalidationReason.MISSING_PROVENANCE, - InvalidationReason.VERSION_MISMATCH, - }.issubset(all_reasons) - - # VERSION_MISMATCH only fires when matches_version is False. - assert InvalidationReason.VERSION_MISMATCH in embed_reasons - assert InvalidationReason.VERSION_MISMATCH not in fts_reasons - - -def test_inventory_rejects_unknown_scope(workspace_env: dict[str, Path]) -> None: - db_path = db_setup(workspace_env) - SessionBuilder(db_path, "x").add_message(role="user", text="hi").save() - - with pytest.raises(ValueError, match="Unknown preview scopes"): - staleness_inventory(db_path, scopes=("bogus",)) - - -def test_inventory_respects_scope_filtering(workspace_env: dict[str, Path]) -> None: - db_path = db_setup(workspace_env) - SessionBuilder(db_path, "scope-1").add_message(role="user", text="hi").save() - - derived_only = staleness_inventory(db_path, scopes=("derived",)) - scopes_emitted = {item.scope for item in derived_only.items} - assert scopes_emitted == {"derived"} - - archive_only = staleness_inventory(db_path, scopes=("archive_cleanup",)) - assert {item.scope for item in archive_only.items} == {"archive_cleanup"} - - -# --------------------------------------------------------------------------- -# CLI surface -# --------------------------------------------------------------------------- - - -def test_cli_preview_json_renders_inventory(workspace_env: dict[str, Path]) -> None: - db_setup(workspace_env) - - runner = CliRunner() - result = runner.invoke( - maintenance_group, - ["preview", "--output-format", "json"], - obj=None, - ) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert "items" in payload - assert "captured_at" in payload - assert "total_stale" in payload - assert isinstance(payload["items"], list) - - -def test_cli_preview_plain_renders_per_model_sections(workspace_env: dict[str, Path]) -> None: - db_path = db_setup(workspace_env) - SessionBuilder(db_path, "plain-1").add_message(role="user", text="hi").save() - - runner = CliRunner() - result = runner.invoke( - maintenance_group, - ["preview"], - obj=None, - ) - assert result.exit_code == 0, result.output - assert "Captured:" in result.output - assert "Total stale rows:" in result.output - # At least one model section header should appear. - assert "messages_fts:" in result.output - - -def test_cli_preview_scope_filter_passes_through(workspace_env: dict[str, Path]) -> None: - db_setup(workspace_env) - runner = CliRunner() - result = runner.invoke( - maintenance_group, - ["preview", "--scope", "archive_cleanup", "--output-format", "json"], - obj=None, - ) - assert result.exit_code == 0, result.output - payload = json.loads(result.output) - assert payload["scopes"] == ["archive_cleanup"] - for item in payload["items"]: - assert item["scope"] == "archive_cleanup" diff --git a/tests/unit/maintenance/test_registry.py b/tests/unit/maintenance/test_registry.py deleted file mode 100644 index ceaec2cf76..0000000000 --- a/tests/unit/maintenance/test_registry.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Persistent maintenance operation registry tests (#1197). - -Covers: - -* round-trip — a replay snapshot persisted by ``_checkpoint_state`` - rehydrates through the registry into a structurally equal - :class:`~polylogue.maintenance.planner.BackfillOperation`; -* listing — newest-first ordering and filtering by status; -* TTL pruning — only completed-successful operations older than the - TTL are removed; failed operations stay forever; -* end-to-end — a real ``execute_replay`` run with a failing target - leaves a readable registry entry that carries the failure samples. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from pathlib import Path - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.core.json import dumps -from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - BoundedFailureSamples, - FailureSample, - MaintenanceScope, -) -from polylogue.maintenance.registry import ( - DEFAULT_COMPLETED_TTL, - MaintenanceOperationRegistry, -) -from polylogue.maintenance.replay import execute_replay, state_path_for - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - archive_root.mkdir(parents=True, exist_ok=True) - return Config(archive_root=archive_root, render_root=tmp_path / "render", sources=[]) - - -def _write_legacy_state(config: Config, operation_id: str, *, updated_at: str, status: str) -> Path: - """Write a state file using the upgraded ``operation``-bearing payload.""" - operation = BackfillOperation( - operation_id=operation_id, - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus(status), - progress=1.0 if status != "running" else 0.5, - started_at="2026-05-17T00:00:00+00:00", - completed_at=updated_at if status != "running" else None, - scope=MaintenanceScope(targets=("session_insights",)), - resume_cursor="done" if status == "completed" else "target:1", - affected_rows=7, - ) - payload = { - "operation_id": operation_id, - "targets": ["session_insights"], - "cursor": operation.resume_cursor, - "started_at": operation.started_at, - "updated_at": updated_at, - "dry_run": False, - "repaired_count": operation.affected_rows, - "failure_count": 0, - "results": [], - "operation": operation.to_dict(), - } - path = state_path_for(config, operation_id) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(dumps(payload)) - return path - - -class TestRegistryRoundTrip: - """A persisted snapshot must rehydrate into a structurally-equal record.""" - - def test_get_operation_round_trips(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - _write_legacy_state(config, "op-1", updated_at="2026-05-17T12:00:00+00:00", status="completed") - registry = MaintenanceOperationRegistry(config=config) - record = registry.get_operation("op-1") - assert record is not None - assert record.operation_id == "op-1" - assert record.status is OperationStatus.COMPLETED - assert record.operation.targets == ("session_insights",) - assert record.operation.affected_rows == 7 - - def test_get_operation_missing_returns_none(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - registry = MaintenanceOperationRegistry(config=config) - assert registry.get_operation("does-not-exist") is None - - def test_list_orders_newest_first(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - _write_legacy_state(config, "op-old", updated_at="2026-05-01T00:00:00+00:00", status="completed") - _write_legacy_state(config, "op-mid", updated_at="2026-05-10T00:00:00+00:00", status="failed") - _write_legacy_state(config, "op-new", updated_at="2026-05-15T00:00:00+00:00", status="running") - registry = MaintenanceOperationRegistry(config=config) - ids = [r.operation_id for r in registry.list_operations()] - assert ids == ["op-new", "op-mid", "op-old"] - - def test_list_skips_unparseable_files(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - _write_legacy_state(config, "op-ok", updated_at="2026-05-17T00:00:00+00:00", status="completed") - bad = state_path_for(config, "op-broken") - bad.parent.mkdir(parents=True, exist_ok=True) - bad.write_text("{not-json") - registry = MaintenanceOperationRegistry(config=config) - ids = [r.operation_id for r in registry.list_operations()] - assert ids == ["op-ok"] - - def test_diagnostic_listing_reports_unparseable_files(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - bad = state_path_for(config, "op-broken") - bad.parent.mkdir(parents=True, exist_ok=True) - bad.write_text("{not-json") - registry = MaintenanceOperationRegistry(config=config) - records, issues = registry.list_operations_diagnostic() - assert records == () - assert len(issues) == 1 - assert issues[0].code == "invalid_json" - assert issues[0].path == bad - - def test_missing_directory_lists_empty(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - registry = MaintenanceOperationRegistry(config=config) - assert registry.list_operations() == () - - -class TestRegistryPrune: - """``prune_completed`` removes completed-successful operations past the TTL.""" - - def test_prune_drops_old_completed_operations(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - old_ts = "2026-04-01T00:00:00+00:00" - new_ts = "2026-05-17T00:00:00+00:00" - _write_legacy_state(config, "op-old", updated_at=old_ts, status="completed") - _write_legacy_state(config, "op-new", updated_at=new_ts, status="completed") - registry = MaintenanceOperationRegistry(config=config) - # Reference: 2026-05-15, default TTL 7d → cutoff 2026-05-08; - # old (April) is past cutoff, new (May 17) is not yet past. - pruned = registry.prune_completed(now=datetime(2026, 5, 15, tzinfo=timezone.utc)) - assert pruned == ("op-old",) - remaining_ids = [r.operation_id for r in registry.list_operations()] - assert remaining_ids == ["op-new"] - - def test_prune_never_removes_failed_operations(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - old_ts = "2025-01-01T00:00:00+00:00" # Year-old failure. - _write_legacy_state(config, "op-failed", updated_at=old_ts, status="failed") - registry = MaintenanceOperationRegistry(config=config) - pruned = registry.prune_completed( - older_than=timedelta(seconds=1), - now=datetime(2026, 5, 15, tzinfo=timezone.utc), - ) - assert pruned == () - assert {r.operation_id for r in registry.list_operations()} == {"op-failed"} - - def test_prune_default_ttl_is_seven_days(self) -> None: - assert timedelta(days=7) == DEFAULT_COMPLETED_TTL - - def test_prune_ignores_running_operations(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - _write_legacy_state(config, "op-running", updated_at="2025-01-01T00:00:00+00:00", status="running") - registry = MaintenanceOperationRegistry(config=config) - pruned = registry.prune_completed( - older_than=timedelta(seconds=1), - now=datetime(2026, 5, 15, tzinfo=timezone.utc), - ) - assert pruned == () - - -class TestReplayWritesRegistryReadableState: - """A real failing replay run must leave a snapshot the registry can read.""" - - def test_failed_replay_leaves_registry_entry(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - # An unknown target name forces the executor to record a - # FailureSample and finish with status=FAILED, which exercises - # the new persistence-on-failure branch. - result = execute_replay( - config, - targets=("session_insights",), - operation_id="op-failure", - dry_run=False, - persist_state=True, - ) - # Even when session_insights succeeds on an empty archive, we - # care about the registry-readable shape: the executor either - # cleared the file (success) or wrote a snapshot (failure). We - # accept either branch, but if a state file exists it must be - # rehydratable. - registry = MaintenanceOperationRegistry(config=config) - record = registry.get_operation(result.operation_id) - if record is not None: - assert record.operation.operation_id == result.operation_id - # The persisted snapshot's targets must match what the - # executor reported, regardless of success/failure. - assert record.operation.targets == result.targets - - def test_explicit_failure_via_unsupported_target(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - # Patch the real handler dict for the duration of this test to - # inject an unknown-target failure deterministically. - from polylogue.storage import repair as repair_mod - - original = dict(repair_mod.REPAIR_HANDLERS) - repair_mod.REPAIR_HANDLERS.clear() - try: - result = execute_replay( - config, - targets=("session_insights",), - operation_id="op-unsupported", - persist_state=True, - ) - finally: - repair_mod.REPAIR_HANDLERS.clear() - repair_mod.REPAIR_HANDLERS.update(original) - - # The empty dispatch table makes catalog resolution fail (no - # supported targets), which surfaces as a FAILED operation - # before the executor enters the per-target loop. The registry - # contract still applies: either a snapshot is written or not, - # but if it is, it must be readable. - registry = MaintenanceOperationRegistry(config=config) - record = registry.get_operation(result.operation_id) - if record is not None: - assert record.operation.operation_id == "op-unsupported" - - -class TestRegistryPreservesScope: - """Persisted ``scope`` round-trips through the registry.""" - - def test_scope_filter_round_trips(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - from polylogue.maintenance.scope import MaintenanceScopeFilter - - op = BackfillOperation( - operation_id="op-scope", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.RUNNING, - scope=MaintenanceScope( - targets=("session_insights",), - filter=MaintenanceScopeFilter(origin="claude-code-session"), - ), - ) - payload = { - "operation_id": "op-scope", - "targets": ["session_insights"], - "cursor": "target:0", - "started_at": "2026-05-17T00:00:00+00:00", - "updated_at": "2026-05-17T00:01:00+00:00", - "dry_run": False, - "repaired_count": 0, - "failure_count": 0, - "results": [], - "operation": op.to_dict(), - } - path = state_path_for(config, "op-scope") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(dumps(payload)) - - registry = MaintenanceOperationRegistry(config=config) - record = registry.get_operation("op-scope") - assert record is not None - assert record.operation.scope is not None - assert record.operation.scope.filter.origin == "claude-code-session" - - -class TestRegistryFailureSamplesPersist: - """Bounded failure samples must round-trip through the on-disk format.""" - - def test_failure_samples_round_trip(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - op = BackfillOperation( - operation_id="op-fails", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.FAILED, - failure_samples=BoundedFailureSamples.from_samples( - [FailureSample(kind="RuntimeError", locator="target:session_insights", message="boom")] - ), - ) - payload = { - "operation_id": "op-fails", - "targets": ["session_insights"], - "cursor": "done", - "started_at": "2026-05-17T00:00:00+00:00", - "updated_at": "2026-05-17T00:00:01+00:00", - "dry_run": False, - "repaired_count": 0, - "failure_count": 1, - "results": [], - "operation": op.to_dict(), - } - path = state_path_for(config, "op-fails") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(dumps(payload)) - - registry = MaintenanceOperationRegistry(config=config) - record = registry.get_operation("op-fails") - assert record is not None - samples = record.operation.failure_samples.samples - assert len(samples) == 1 - assert samples[0].kind == "RuntimeError" - assert samples[0].message == "boom" - - -@pytest.fixture -def registry_with_one_record(tmp_path: Path) -> tuple[MaintenanceOperationRegistry, Config]: - config = _make_config(tmp_path) - _write_legacy_state(config, "op-one", updated_at="2026-05-17T00:00:00+00:00", status="running") - return MaintenanceOperationRegistry(config=config), config - - -class TestRegistryToDict: - """Operation records serialize through ``to_dict`` for surface usage.""" - - def test_to_dict_carries_envelope_keys( - self, - registry_with_one_record: tuple[MaintenanceOperationRegistry, Config], - ) -> None: - registry, _ = registry_with_one_record - records = registry.list_operations() - assert len(records) == 1 - payload = records[0].to_dict() - op_payload = payload["operation"] - assert isinstance(op_payload, dict) - assert op_payload["operation_id"] == "op-one" - assert payload["updated_at"] == "2026-05-17T00:00:00+00:00" - state_path = payload["state_path"] - assert isinstance(state_path, str) - assert state_path.endswith("op-one.json") diff --git a/tests/unit/maintenance/test_resume.py b/tests/unit/maintenance/test_resume.py deleted file mode 100644 index d3636a2825..0000000000 --- a/tests/unit/maintenance/test_resume.py +++ /dev/null @@ -1,1108 +0,0 @@ -"""Resume tests for :mod:`polylogue.maintenance.replay` (#1147). - -These pin the resume contract from the issue acceptance criteria: - -* an interrupted replay leaves an on-disk state file that records the - last completed target via a typed cursor; -* re-invoking the same ``operation_id`` resumes from that cursor and - does not re-run already-completed targets; -* a successful replay clears the state file so a later run with the - same id starts fresh. -""" - -from __future__ import annotations - -from collections.abc import Iterator -from datetime import datetime, timezone -from pathlib import Path -from typing import Any -from unittest.mock import patch - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance import replay as replay_module -from polylogue.maintenance.models import MaintenanceCategory -from polylogue.maintenance.replay import ( - CURSOR_DONE, - ReplayProgress, - clear_state, - execute_replay, - load_state, - state_path_for, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter -from polylogue.storage import repair as repair_module -from polylogue.storage.repair import RepairResult - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -def _ok_result(name: str, repaired: int = 1, metrics: dict[str, float] | None = None) -> RepairResult: - return RepairResult( - name=name, - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=repaired, - success=True, - detail=f"{name} ok", - metrics=dict(metrics or {}), - ) - - -@pytest.fixture -def patched_dispatch() -> Iterator[dict[str, list[str]]]: - """Replace the replay dispatch table with stub repair functions. - - Yields a call log keyed by target name so tests can assert which - targets executed under each invocation. - """ - - calls: dict[str, list[str]] = { - "session_insights": [], - "empty_sessions": [], - "superseded_raw_snapshots": [], - } - - def stub(name: str): # type: ignore[no-untyped-def] - def _run(config: Config, dry_run: bool) -> RepairResult: - calls[name].append("dry" if dry_run else "live") - return _ok_result(name) - - return _run - - fake_dispatch = {name: stub(name) for name in calls} - with patch.object(repair_module, "REPAIR_HANDLERS", fake_dispatch): - yield calls - - -def test_clean_run_persists_done_and_clears_state(tmp_path: Path, patched_dispatch: dict[str, list[str]]) -> None: - config = _make_config(tmp_path) - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-clean", - ) - - assert op.status is OperationStatus.COMPLETED - assert op.resume_cursor == CURSOR_DONE - # State file is removed after successful completion. - assert not state_path_for(config, "op-clean").exists() - assert patched_dispatch["session_insights"] == ["live"] - - -def test_completed_prefix_cursor_uses_historical_target_identity( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - """A historical cursor remains valid after completed targets are filtered.""" - config = _make_config(tmp_path) - path = state_path_for(config, "op-historical-prefix") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-historical-prefix",' - '"targets":["session_insights","message_type_backfill","empty_sessions"],' - '"completed_targets":["session_insights","message_type_backfill"],"cursor":"target:2"}' - ) - - op = execute_replay( - config, - targets=("empty_sessions",), - operation_id="op-historical-prefix", - ) - - assert op.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["empty_sessions"] == ["live"] - - -def test_positional_persisted_cursor_without_identity_fails_closed( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-unverifiable") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('{"operation_id":"op-unverifiable","cursor":"target:2"}') - - op = execute_replay( - config, - targets=("session_insights", "superseded_raw_snapshots"), - operation_id="op-unverifiable", - ) - - assert op.status is OperationStatus.FAILED - assert op.error == "Persisted replay state has no valid target identity list" - assert op.failure_samples.samples[0].kind == "IncompatibleReplayState" - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["superseded_raw_snapshots"] == [] - - -def test_chained_resume_retains_completed_identity_history( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-chained") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-chained",' - '"targets":["session_insights","empty_sessions","superseded_raw_snapshots"],' - '"completed_targets":["session_insights"],' - '"cursor":"target:0"}' - ) - - def fail_empty(_config: Config, _dry_run: bool) -> RepairResult: - raise RuntimeError("interrupted") - - with patch.object( - repair_module, - "REPAIR_HANDLERS", - { - "session_insights": patched_dispatch_callable(patched_dispatch, "session_insights"), - "empty_sessions": fail_empty, - "superseded_raw_snapshots": patched_dispatch_callable(patched_dispatch, "superseded_raw_snapshots"), - }, - ): - first = execute_replay( - config, - targets=("session_insights", "empty_sessions", "superseded_raw_snapshots"), - operation_id="op-chained", - ) - assert first.status is OperationStatus.FAILED - checkpoint = load_state(config, "op-chained") - assert checkpoint is not None - assert checkpoint["targets"] == ["session_insights", "empty_sessions", "superseded_raw_snapshots"] - assert checkpoint["completed_targets"] == ["session_insights", "superseded_raw_snapshots"] - - second = execute_replay( - config, - targets=("session_insights", "empty_sessions", "superseded_raw_snapshots"), - operation_id="op-chained", - ) - - assert second.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["empty_sessions"] == ["live"] - assert patched_dispatch["superseded_raw_snapshots"] == ["live"] - - -@pytest.mark.parametrize("contents", ["[]", "null", "not-json"]) -def test_invalid_persisted_state_fails_closed( - tmp_path: Path, patched_dispatch: dict[str, list[str]], contents: str -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-invalid") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(contents) - - op = execute_replay( - config, - targets=("session_insights", "superseded_raw_snapshots"), - operation_id="op-invalid", - ) - - assert op.status is OperationStatus.FAILED - assert op.error == "Persisted replay state is not a JSON object" - assert op.failure_samples.samples[0].kind == "InvalidReplayState" - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["superseded_raw_snapshots"] == [] - - -def test_generated_checkpoint_cursor_is_legacy_only_and_failed_target_retries( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - - def reported_failure(_config: Config, _dry_run: bool) -> RepairResult: - return RepairResult( - name="superseded_raw_snapshots", - category=MaintenanceCategory.ARCHIVE_CLEANUP, - destructive=True, - repaired_count=3, - success=False, - detail="retry me", - metrics={"attempted": 3.0}, - ) - - with patch.object( - repair_module, - "REPAIR_HANDLERS", - { - "session_insights": patched_dispatch_callable(patched_dispatch, "session_insights"), - "empty_sessions": patched_dispatch_callable(patched_dispatch, "empty_sessions"), - "superseded_raw_snapshots": reported_failure, - }, - ): - first = execute_replay( - config, - targets=("session_insights", "empty_sessions", "superseded_raw_snapshots"), - operation_id="op-retry", - ) - - assert first.status is OperationStatus.FAILED - checkpoint = load_state(config, "op-retry") - assert checkpoint is not None - assert checkpoint["completed_targets"] == ["session_insights", "empty_sessions"] - assert checkpoint["cursor"] == "target:0" - - second = execute_replay( - config, - targets=("session_insights", "empty_sessions", "superseded_raw_snapshots"), - operation_id="op-retry", - ) - - assert second.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == ["live"] - assert patched_dispatch["empty_sessions"] == ["live"] - assert patched_dispatch["superseded_raw_snapshots"] == ["live"] - - -def test_resume_aggregates_receipt_data_and_current_progress_after_remap( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-receipt") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-receipt",' - '"targets":["session_insights","empty_sessions","superseded_raw_snapshots"],' - '"completed_targets":["session_insights"],"cursor":"target:0",' - '"started_at":"2026-01-01T00:00:00+00:00",' - '"results":[{"name":"session_insights","repaired_count":4,"success":true}],' - '"repaired_count":4,"failure_count":1,' - '"failure_samples":[{"kind":"old","locator":"target:empty_sessions","message":"old"}],' - '"metrics":{"prior":2.0}}' - ) - snapshots: list[ReplayProgress] = [] - - op = execute_replay( - config, - targets=("superseded_raw_snapshots", "empty_sessions"), - operation_id="op-receipt", - progress_callback=snapshots.append, - ) - - assert op.status is OperationStatus.COMPLETED - assert op.started_at == "2026-01-01T00:00:00+00:00" - assert len(op.results) == 3 - assert op.affected_rows == 6 - assert op.metrics["prior"] == 2.0 - assert op.failure_samples.samples[0].kind == "old" - assert snapshots and {snapshot.total for snapshot in snapshots} == {2} - assert [snapshot.target for snapshot in snapshots] == ["superseded_raw_snapshots", "empty_sessions"] - - -def test_fresh_explicit_done_and_malformed_cursor_are_typed_noops_or_failures( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - - done = execute_replay( - config, - targets=("session_insights",), - operation_id="op-fresh-done", - resume_cursor=CURSOR_DONE, - ) - malformed = execute_replay( - config, - targets=("session_insights",), - operation_id="op-fresh-malformed", - resume_cursor="not-a-cursor", - ) - - assert done.status is OperationStatus.COMPLETED - assert done.progress == 1.0 - assert malformed.status is OperationStatus.FAILED - assert malformed.failure_samples.samples[0].kind == "InvalidReplayCursor" - assert patched_dispatch["session_insights"] == [] - - -@pytest.mark.parametrize("resume_cursor", ["", "not-a-cursor", "target:not-an-integer", "target:-1"]) -def test_explicit_malformed_cursor_precedes_blocker_and_preserves_state( - tmp_path: Path, - patched_dispatch: dict[str, list[str]], - resume_cursor: str, -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-invalid-before-blocker") - path.parent.mkdir(parents=True, exist_ok=True) - original = '{"operation_id":"op-invalid-before-blocker","cursor":"target:0"}' - path.write_text(original) - - blocker = _ok_result("session_insights", repaired=0) - blocker = RepairResult( - name=blocker.name, - category=blocker.category, - destructive=blocker.destructive, - repaired_count=0, - success=False, - detail="daemon is running", - ) - with patch( - "polylogue.maintenance.replay.offline_maintenance_blockers", - return_value=[blocker], - ) as blocker_check: - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-invalid-before-blocker", - resume_cursor=resume_cursor, - ) - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "InvalidReplayCursor" - blocker_check.assert_not_called() - assert path.read_text() == original - assert patched_dispatch["session_insights"] == [] - - -def test_explicit_malformed_cursor_without_state_does_not_create_state( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - with patch("polylogue.maintenance.replay.offline_maintenance_blockers") as blocker_check: - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-invalid-no-state", - resume_cursor="", - ) - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "InvalidReplayCursor" - blocker_check.assert_not_called() - assert not state_path_for(config, "op-invalid-no-state").exists() - assert patched_dispatch["session_insights"] == [] - - -def test_explicit_resume_cursor_maps_reordered_subset_by_identity( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-reorder") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-reorder",' - '"targets":["session_insights","empty_sessions","superseded_raw_snapshots"],' - '"completed_targets":["session_insights"],"cursor":"target:0"}' - ) - - op = execute_replay( - config, - targets=("superseded_raw_snapshots", "empty_sessions"), - operation_id="op-reorder", - resume_cursor="target:0", - ) - - assert op.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["superseded_raw_snapshots"] == ["live"] - assert patched_dispatch["empty_sessions"] == ["live"] - - -def test_legacy_done_cursor_uses_success_records_and_retries_failed_target( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-legacy-retry") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-legacy-retry",' - '"targets":["session_insights","empty_sessions"],"cursor":"done",' - '"results":[{"name":"session_insights","success":true,"repaired_count":1}]}' - ) - - op = execute_replay( - config, - targets=("session_insights", "empty_sessions"), - operation_id="op-legacy-retry", - ) - - assert op.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["empty_sessions"] == ["live"] - - -def test_legacy_done_without_authoritative_success_does_not_clear_state( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-legacy-unknown") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('{"operation_id":"op-legacy-unknown","targets":["session_insights"],"cursor":"done"}') - - op = execute_replay(config, targets=("session_insights",), operation_id="op-legacy-unknown") - - assert op.status is OperationStatus.FAILED - assert op.error == "Legacy replay state has no authoritative successful targets" - assert state_path_for(config, "op-legacy-unknown").exists() - assert patched_dispatch["session_insights"] == [] - - -def test_resume_rejects_mode_and_scope_context_changes(tmp_path: Path, patched_dispatch: dict[str, list[str]]) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-context") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-context","targets":["session_insights"],' - '"completed_targets":[],"cursor":"target:0","dry_run":true,' - '"scope_filter":{"session_ids":["s-1"],"origin":null,"source_family":null,' - '"source_root":null,"time_range":null,"failure_kind":null,"parser_version":null}}' - ) - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-context", - dry_run=False, - scope_filter=MaintenanceScopeFilter(session_ids=("s-2",)), - ) - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "ReplayContextMismatch" - assert patched_dispatch["session_insights"] == [] - - -def test_nested_receipt_fields_do_not_double_count_metrics( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-nested-receipt") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-nested-receipt","targets":["session_insights"],' - '"completed_targets":[],"cursor":"target:0","results":[],"metrics":{},' - '"operation":{"started_at":"2026-02-03T04:05:06+00:00",' - '"metrics":{"same":7.0},"failure_samples":{"samples":[],"truncated":false}}}' - ) - - op = execute_replay(config, targets=("session_insights",), operation_id="op-nested-receipt") - - assert op.status is OperationStatus.COMPLETED - assert op.started_at == "2026-02-03T04:05:06+00:00" - assert op.metrics["same"] == 7.0 - - -def test_blocker_receipt_retains_nested_cumulative_metrics( - tmp_path: Path, - patched_dispatch: dict[str, list[str]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-blocked-receipt") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-blocked-receipt","targets":["session_insights"],' - '"completed_targets":[],"cursor":"target:0","metrics":{},' - '"operation":{"started_at":"2026-02-03T04:05:06+00:00",' - '"metrics":{"same":7.0},"failure_samples":{"samples":[],"truncated":false}}}' - ) - monkeypatch.setattr( - "polylogue.maintenance.replay.offline_maintenance_blockers", - lambda *args, **kwargs: [_ok_result("session_insights", repaired=0)], - ) - - op = execute_replay(config, targets=("session_insights",), operation_id="op-blocked-receipt") - - assert op.status is OperationStatus.FAILED - assert op.metrics["same"] == 7.0 - assert op.metrics["repaired_count"] == 0.0 - persisted = load_state(config, "op-blocked-receipt") - assert persisted is not None - operation = persisted.get("operation") - assert isinstance(operation, dict) - scope = operation.get("scope") - assert isinstance(scope, dict) - assert scope.get("filter") == MaintenanceScopeFilter().to_dict() - assert patched_dispatch["session_insights"] == [] - - -def test_legacy_cursor_with_failure_samples_fails_closed( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-legacy-failure-prefix") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-legacy-failure-prefix",' - '"targets":["session_insights","empty_sessions"],"cursor":"target:1",' - '"failure_samples":[{"kind":"RuntimeError","locator":"target:session_insights",' - '"message":"failed"}]}' - ) - - op = execute_replay(config, targets=("session_insights", "empty_sessions"), operation_id="op-legacy-failure-prefix") - - assert op.status is OperationStatus.FAILED - assert "failure samples" in (op.error or "") - assert patched_dispatch["session_insights"] == [] - assert patched_dispatch["empty_sessions"] == [] - - -def test_missing_persisted_cursor_is_invalid_not_fresh_execution( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-missing-cursor") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('{"operation_id":"op-missing-cursor","targets":["session_insights"]}') - - op = execute_replay(config, targets=("session_insights",), operation_id="op-missing-cursor") - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "InvalidReplayCursor" - assert patched_dispatch["session_insights"] == [] - - -def test_scoped_resume_requires_persisted_scope_identity( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-missing-scope") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-missing-scope","targets":["session_insights"],"completed_targets":[],"cursor":"target:0"}' - ) - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-missing-scope", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - - assert op.status is OperationStatus.FAILED - assert op.failure_samples.samples[0].kind == "ReplayContextMismatch" - assert patched_dispatch["session_insights"] == [] - - -def test_replay_passes_session_scope_to_empty_session_cleanup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - config = _make_config(tmp_path) - seen: list[tuple[str, ...] | None] = [] - - def empty_sessions(_config: Config, _dry_run: bool, *, session_ids: tuple[str, ...] | None = None) -> RepairResult: - seen.append(session_ids) - return _ok_result("empty_sessions") - - monkeypatch.setattr(replay_module, "repair_empty_sessions", empty_sessions) - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "empty_sessions", empty_sessions) - - operation = execute_replay( - config, - targets=("empty_sessions",), - operation_id="op-empty-session-scope", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1", "s-2")), - ) - - assert operation.status is OperationStatus.COMPLETED - assert seen == [("s-1", "s-2")] - - -def test_replay_refuses_target_that_cannot_honor_session_scope( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - operation = execute_replay( - config, - targets=("superseded_raw_snapshots",), - operation_id="op-unsupported-session-scope", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - - assert operation.status is OperationStatus.FAILED - assert operation.resume_cursor == CURSOR_DONE - assert operation.failure_samples.samples[0].kind == "UnsupportedScopeDimension" - assert patched_dispatch["superseded_raw_snapshots"] == [] - assert not (Path(config.archive_root) / ".maintenance-state" / "failures.jsonl").exists() - - resumed = execute_replay( - config, - targets=("superseded_raw_snapshots",), - operation_id="op-unsupported-session-scope", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - - assert resumed.status is OperationStatus.FAILED - assert resumed.resume_cursor == CURSOR_DONE - assert len(resumed.failure_samples.samples) == 1 - assert patched_dispatch["superseded_raw_snapshots"] == [] - - -def test_legacy_result_metrics_are_reconstructed_when_aggregate_is_absent( - tmp_path: Path, patched_dispatch: dict[str, list[str]], monkeypatch: pytest.MonkeyPatch -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-legacy-metrics") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-legacy-metrics",' - '"targets":["session_insights","empty_sessions"],"cursor":"done",' - '"results":[{"name":"session_insights","success":true,"metrics":{"same":7.0}}]}' - ) - monkeypatch.setitem( - repair_module.REPAIR_HANDLERS, - "empty_sessions", - lambda _config, _dry_run: _ok_result("empty_sessions", metrics={"same": 2.0}), - ) - - op = execute_replay(config, targets=("session_insights", "empty_sessions"), operation_id="op-legacy-metrics") - - assert op.status is OperationStatus.COMPLETED - assert op.metrics["same"] == 9.0 - - -def test_empty_persisted_cursor_fails_closed_instead_of_replaying( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-empty-cursor") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text('{"operation_id":"op-empty-cursor","targets":["session_insights"],"cursor":""}') - - original = path.read_text() - op = execute_replay(config, targets=("session_insights",), operation_id="op-empty-cursor") - - assert op.status is OperationStatus.FAILED - assert op.error == "Persisted replay state has an invalid target cursor" - assert op.failure_samples.samples[0].kind == "InvalidReplayCursor" - assert patched_dispatch["session_insights"] == [] - assert path.read_text() == original - - -def test_progress_processed_is_monotonic_through_failure_and_inner_progress( - tmp_path: Path, patched_dispatch: dict[str, list[str]], monkeypatch: pytest.MonkeyPatch -) -> None: - config = _make_config(tmp_path) - progress: list[ReplayProgress] = [] - - def session_insights( - _config: Config, - _dry_run: bool, - *, - session_ids: tuple[str, ...] | None, - progress_callback: Any, - ) -> RepairResult: - del session_ids - progress_callback(4, "inner") - return _ok_result("session_insights") - - def fail(_config: Config, _dry_run: bool) -> RepairResult: - raise RuntimeError("failed target") - - monkeypatch.setattr(replay_module, "repair_session_insights", session_insights) - with patch.object( - repair_module, - "REPAIR_HANDLERS", - { - "session_insights": session_insights, - "empty_sessions": fail, - "superseded_raw_snapshots": patched_dispatch_callable(patched_dispatch, "superseded_raw_snapshots"), - }, - ): - op = execute_replay( - config, - targets=("session_insights", "empty_sessions", "superseded_raw_snapshots"), - operation_id="op-progress-failure", - progress_callback=progress.append, - ) - - assert op.status is OperationStatus.FAILED - processed = [snapshot.processed for snapshot in progress] - assert processed == sorted(processed) - assert processed[-3:] == [1, 2, 3] - assert all(snapshot.total == 3 for snapshot in progress) - - -def test_nested_metrics_add_new_results_without_double_counting_prior_rows( - tmp_path: Path, patched_dispatch: dict[str, list[str]], monkeypatch: pytest.MonkeyPatch -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-metric-resume") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-metric-resume","targets":["session_insights"],' - '"completed_targets":[],"cursor":"target:0","metrics":{},' - '"results":[{"name":"old","success":true,"metrics":{"same":7.0}}],' - '"operation":{"metrics":{"same":7.0},"failure_samples":{"samples":[],"truncated":false}}}' - ) - - monkeypatch.setitem( - repair_module.REPAIR_HANDLERS, - "session_insights", - lambda _config, _dry_run: _ok_result("session_insights", metrics={"same": 2.0}), - ) - op = execute_replay(config, targets=("session_insights",), operation_id="op-metric-resume") - - assert op.status is OperationStatus.COMPLETED - assert op.metrics["same"] == 9.0 - - -def test_nested_truncation_flag_survives_completed_resume( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-truncated-receipt") - path.parent.mkdir(parents=True, exist_ok=True) - samples = ",".join('{"kind":"old","locator":"target:x","message":"old"}' for _ in range(50)) - path.write_text( - '{"operation_id":"op-truncated-receipt","targets":["session_insights"],' - '"completed_targets":["session_insights"],"cursor":"target:0",' - '"failure_samples":[' + samples + "]," - '"operation":{"failure_samples":{"samples":[' + samples + '],"truncated":true}}}' - ) - - op = execute_replay(config, targets=("session_insights",), operation_id="op-truncated-receipt") - - assert op.status is OperationStatus.COMPLETED - assert op.failure_samples.truncated is True - assert patched_dispatch["session_insights"] == [] - - -def test_scope_identity_normalizes_session_order_and_timezone_instants( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-scope-equivalent") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - '{"operation_id":"op-scope-equivalent","targets":["session_insights"],' - '"completed_targets":["session_insights"],"cursor":"target:0",' - '"scope_filter":{"session_ids":["s-2","s-1"],"origin":null,"source_family":null,' - '"source_root":null,"time_range":["2026-01-01T01:00:00+01:00","2026-01-02T01:00:00+01:00"],' - '"failure_kind":null,"parser_version":null}}' - ) - scope = MaintenanceScopeFilter( - session_ids=("s-1", "s-2"), - time_range=(datetime(2026, 1, 1, tzinfo=timezone.utc), datetime(2026, 1, 2, tzinfo=timezone.utc)), - ) - - op = execute_replay(config, targets=("session_insights",), operation_id="op-scope-equivalent", scope_filter=scope) - - assert op.status is OperationStatus.COMPLETED - assert patched_dispatch["session_insights"] == [] - - -def test_failure_samples_are_bounded_across_resume_retries( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - path = state_path_for(config, "op-many-failures") - path.parent.mkdir(parents=True, exist_ok=True) - samples = ",".join('{"kind":"old","locator":"target:session_insights","message":"old"}' for _ in range(75)) - path.write_text( - '{"operation_id":"op-many-failures","targets":["session_insights"],' - '"completed_targets":[],"cursor":"target:0","failure_samples":[' + samples + "]}" - ) - - op = execute_replay(config, targets=("session_insights",), operation_id="op-many-failures") - - assert len(op.failure_samples.samples) <= 50 - - -def test_explicit_resume_cursor_overrides_persisted_state( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - config = _make_config(tmp_path) - # Seed a stale persisted state claiming "done". - state_path_for(config, "op-explicit").parent.mkdir(parents=True, exist_ok=True) - state_path_for(config, "op-explicit").write_text('{"operation_id": "op-explicit", "cursor": "done"}') - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-explicit", - resume_cursor="target:1", - ) - - assert op.status is OperationStatus.COMPLETED - # Only the second target was executed (skipped session_insights). - assert patched_dispatch["session_insights"] == [] - - -def test_progress_callback_fires_per_target(tmp_path: Path, patched_dispatch: dict[str, list[str]]) -> None: - config = _make_config(tmp_path) - snapshots: list[ReplayProgress] = [] - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-progress", - progress_callback=snapshots.append, - ) - - assert op.status is OperationStatus.COMPLETED - assert [s.target for s in snapshots] == ["session_insights"] - assert snapshots[0].processed == 1 and snapshots[0].total == 1 - assert snapshots[-1].cursor == CURSOR_DONE - assert snapshots[-1].in_flight_failures == 0 - - -def test_session_insight_progress_is_forwarded_within_target( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - config = _make_config(tmp_path) - snapshots: list[ReplayProgress] = [] - - def repair_with_progress( - _config: Config, - _dry_run: bool, - *, - progress_callback: Any = None, - progress_total: int | None = None, - session_ids: tuple[str, ...] | None = None, - ) -> RepairResult: - assert progress_total is None - assert session_ids is None - assert callable(progress_callback) - progress_callback(17, desc="rebuild: materialized 17/42 session profiles") - return _ok_result("session_insights", repaired=42) - - monkeypatch.setattr("polylogue.maintenance.replay.repair_session_insights", repair_with_progress) - monkeypatch.setitem( - repair_module.REPAIR_HANDLERS, - "session_insights", - repair_with_progress, - ) - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-progress-inner", - progress_callback=snapshots.append, - ) - - assert op.status is OperationStatus.COMPLETED - assert [snapshot.progress_desc for snapshot in snapshots] == [ - "rebuild: materialized 17/42 session profiles", - None, - ] - assert snapshots[0].processed == 0 - assert snapshots[0].progress_amount == 17 - assert snapshots[-1].processed == 1 - - -def test_replay_operation_metrics_include_result_metrics( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - config = _make_config(tmp_path) - - def repair_with_metrics( - _config: Config, - _dry_run: bool, - ) -> RepairResult: - return _ok_result( - "session_insights", - repaired=2, - metrics={ - "rebuilt_profiles": 100.0, - "source_sessions": 1_609_582_167.0, - }, - ) - - monkeypatch.setitem( - repair_module.REPAIR_HANDLERS, - "session_insights", - repair_with_metrics, - ) - - op = execute_replay( - config, - targets=("session_insights",), - operation_id="op-metrics", - ) - - assert op.status is OperationStatus.COMPLETED - assert op.metrics["repaired_count"] == 2.0 - assert op.metrics["rebuilt_profiles"] == 100.0 - assert op.metrics["source_sessions"] == 1_609_582_167.0 - assert op.results[0]["metrics"] == { - "rebuilt_profiles": 100.0, - "source_sessions": 1_609_582_167.0, - } - - -def test_unresolved_targets_short_circuit(tmp_path: Path) -> None: - config = _make_config(tmp_path) - op = execute_replay(config, targets=("does-not-exist",)) - assert op.status is OperationStatus.FAILED - assert op.targets == () - assert op.error == "No valid targets resolved from input" - - -@pytest.mark.parametrize( - "operation_id", - ("", False, 0, "/tmp/escape", "../escape", "nested/escape", "nested\\\\escape", "/", ".."), -) -def test_operation_ids_reject_falsey_and_path_traversal_before_state_path(tmp_path: Path, operation_id: Any) -> None: - config = _make_config(tmp_path) - - with pytest.raises(ValueError, match="operation_id"): - state_path_for(config, operation_id) - with pytest.raises(ValueError, match="operation_id"): - execute_replay(config, targets=("session_insights",), operation_id=operation_id) - - assert not (tmp_path / "escape.json").exists() - - -def test_operation_id_none_generates_uuid_and_existing_id_is_preserved(tmp_path: Path) -> None: - config = _make_config(tmp_path) - - generated = execute_replay(config, targets=("session_insights",), operation_id=None, persist_state=False) - assert generated.operation_id - assert generated.operation_id != "None" - - path = state_path_for(config, "legacy-operation-42") - assert path == config.archive_root / ".maintenance-state" / "legacy-operation-42.json" - - -def test_clear_state_is_idempotent(tmp_path: Path) -> None: - config = _make_config(tmp_path) - # Clearing a non-existent state file is a no-op. - clear_state(config, "never-existed") - # Create then clear. - path = state_path_for(config, "later-cleared") - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("{}") - clear_state(config, "later-cleared") - assert not path.exists() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def patched_dispatch_callable(calls: dict[str, list[str]], name: str): # type: ignore[no-untyped-def] - """Return a stub that records into ``calls[name]`` and reports success.""" - - def _run(_config: Config, dry_run: bool) -> RepairResult: - calls[name].append("dry" if dry_run else "live") - return _ok_result(name) - - return _run - - -def patched_dispatch_table(calls: dict[str, list[str]]) -> dict[str, object]: - return {name: patched_dispatch_callable(calls, name) for name in calls} - - -def test_unsupported_scope_is_refused_before_the_offline_gate( - tmp_path: Path, patched_dispatch: dict[str, list[str]] -) -> None: - """A scope a target cannot apply is terminal, not a retryable blocker. - - Anti-vacuity: moving the refusal back inside ``_run_one_target`` (after - ``offline_maintenance_blockers``) makes this red -- the receipt would - carry ``OfflineMaintenanceBlocked`` and invite a retry of a request that - can never succeed. - """ - config = _make_config(tmp_path) - blocker = RepairResult( - name="superseded_raw_snapshots", - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=0, - success=False, - detail="daemon owns writes", - ) - with patch( - "polylogue.maintenance.replay.offline_maintenance_blockers", - return_value=[blocker], - ): - operation = execute_replay( - config, - targets=("superseded_raw_snapshots",), - operation_id="op-scope-before-offline", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - - assert operation.status is OperationStatus.FAILED - assert {sample.kind for sample in operation.failure_samples.samples} == {"UnsupportedScopeDimension"} - - -def test_terminal_scope_refusal_checkpoints_as_failed( - tmp_path: Path, patched_dispatch: dict[str, list[str]], monkeypatch: pytest.MonkeyPatch -) -> None: - """The mid-run checkpoint of a permanent refusal is never COMPLETED. - - The checkpoint written after the last target -- before the final receipt - exists -- is what a crash leaves behind, so it is read back here through - the progress callback that fires immediately after it. - - Anti-vacuity: restoring ``status = COMPLETED if all_completed else - FAILED`` in ``_build_in_progress_snapshot`` makes this red -- the crash - window would persist COMPLETED for a permanently refused request. - """ - config = _make_config(tmp_path) - - def empty_sessions(cfg: Config, dry_run: bool = False, **kwargs: Any) -> RepairResult: - return _ok_result("empty_sessions") - - monkeypatch.setattr(replay_module, "repair_empty_sessions", empty_sessions) - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "empty_sessions", empty_sessions) - - checkpointed: list[str] = [] - - def observe(_progress: ReplayProgress) -> None: - state = load_state(config, "op-refusal-checkpoint") - assert state is not None - snapshot = state["operation"] - assert isinstance(snapshot, dict) - checkpointed.append(str(snapshot["status"])) - - operation = execute_replay( - config, - targets=("empty_sessions", "superseded_raw_snapshots"), - operation_id="op-refusal-checkpoint", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - progress_callback=observe, - ) - assert operation.status is OperationStatus.FAILED - assert checkpointed - assert OperationStatus.COMPLETED.value not in checkpointed - - -def test_resume_ignores_a_refusal_from_an_excluded_target( - tmp_path: Path, patched_dispatch: dict[str, list[str]], monkeypatch: pytest.MonkeyPatch -) -> None: - """A narrowed resume is judged on its own targets only. - - Anti-vacuity: dropping the ``targets`` filter from - ``_has_terminal_scope_refusal`` makes this red -- the hydrated refusal - recorded for ``superseded_raw_snapshots`` would fail a resume that no - longer requests it. - """ - config = _make_config(tmp_path) - - def empty_sessions(cfg: Config, dry_run: bool = False, **kwargs: Any) -> RepairResult: - return _ok_result("empty_sessions") - - monkeypatch.setattr(replay_module, "repair_empty_sessions", empty_sessions) - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "empty_sessions", empty_sessions) - - refused = execute_replay( - config, - targets=("empty_sessions", "superseded_raw_snapshots"), - operation_id="op-narrowed-resume", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - assert refused.status is OperationStatus.FAILED - - resumed = execute_replay( - config, - targets=("empty_sessions",), - operation_id="op-narrowed-resume", - scope_filter=MaintenanceScopeFilter(session_ids=("s-1",)), - ) - assert resumed.status is OperationStatus.COMPLETED diff --git a/tests/unit/maintenance/test_scope_filter.py b/tests/unit/maintenance/test_scope_filter.py deleted file mode 100644 index 62d47a33bc..0000000000 --- a/tests/unit/maintenance/test_scope_filter.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Typed maintenance scope-filter contract (issue #1196). - -Pins: - -* the :class:`MaintenanceScopeFilter` model — fields, frozen-ness, - extra-field rejection, ``is_empty`` / ``to_dict`` / ``from_dict`` - round-trip; -* the planner contract — :func:`preview_backfill` and - :func:`execute_replay` accept the typed filter, surface it on the - returned :class:`BackfillOperation.scope`, and shrink - ``affected_rows`` when ``session_ids`` narrows the scope; -* cross-surface parity — CLI ``polylogue ops maintenance plan``, daemon - ``POST /api/maintenance/plan``, and MCP ``maintenance_preview`` - all serialize an equivalent filter into the same envelope payload. -""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, cast -from unittest.mock import patch - -import pytest -from click.testing import CliRunner -from pydantic import ValidationError - -from polylogue.cli.commands.maintenance import maintenance_group -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.envelope import envelope_from_operation -from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - MaintenanceScope, - preview_backfill, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter - - -class TestMaintenanceScopeFilterShape: - """Pin the typed-filter Pydantic contract.""" - - def test_default_filter_is_empty(self) -> None: - f = MaintenanceScopeFilter() - assert f.is_empty() - assert f.session_ids is None - assert f.origin is None - assert f.source_root is None - assert f.time_range is None - - def test_filter_is_frozen(self) -> None: - f = MaintenanceScopeFilter(origin="claude-code-session") - with pytest.raises(ValidationError): - f.origin = "chatgpt-export" - - def test_filter_rejects_extra_fields(self) -> None: - with pytest.raises(ValidationError): - MaintenanceScopeFilter(unknown_dimension="boom") # type: ignore[call-arg] - - def test_session_ids_coerce_list_to_tuple(self) -> None: - f = MaintenanceScopeFilter(session_ids=["c1", "c2"]) # type: ignore[arg-type] - assert f.session_ids == ("c1", "c2") - - def test_session_ids_coerce_single_string(self) -> None: - f = MaintenanceScopeFilter(session_ids="c1") # type: ignore[arg-type] - assert f.session_ids == ("c1",) - - def test_time_range_accepts_iso_strings(self) -> None: - f = MaintenanceScopeFilter(time_range=("2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z")) # type: ignore[arg-type] - assert f.time_range is not None - since, until = f.time_range - assert since == datetime(2026, 1, 1, tzinfo=timezone.utc) - assert until == datetime(2026, 2, 1, tzinfo=timezone.utc) - - def test_time_range_rejects_single_value(self) -> None: - with pytest.raises(ValidationError): - MaintenanceScopeFilter(time_range=("2026-01-01T00:00:00Z",)) # type: ignore[arg-type] - - def test_source_root_coerces_string_to_path(self) -> None: - f = MaintenanceScopeFilter(source_root="/var/tmp") # type: ignore[arg-type] - assert f.source_root == Path("/var/tmp") - - -class TestMaintenanceScopeFilterRoundTrip: - """from_dict(to_dict(f)) == f for every dimension combination.""" - - @pytest.mark.parametrize( - "filter_kwargs", - [ - {}, - {"session_ids": ("c1",)}, - {"session_ids": ("c1", "c2", "c3")}, - {"origin": "claude-code-session"}, - {"source_family": "claude-code-session"}, - {"source_root": Path("/data/claude")}, - {"time_range": (datetime(2026, 1, 1, tzinfo=timezone.utc), datetime(2026, 2, 1, tzinfo=timezone.utc))}, - {"failure_kind": "ValidationError"}, - {"parser_version": "v3"}, - { - "session_ids": ("c1",), - "origin": "claude-code-session", - "source_family": "claude-code-session", - }, - ], - ) - def test_round_trip(self, filter_kwargs: dict[str, Any]) -> None: - original = MaintenanceScopeFilter(**filter_kwargs) - payload = original.to_dict() - # Payload must be a plain dict with every known dimension. - assert isinstance(payload, dict) - assert "session_ids" in payload - assert "time_range" in payload - recovered = MaintenanceScopeFilter.from_dict(payload) - assert recovered == original - - def test_from_dict_none_is_empty(self) -> None: - assert MaintenanceScopeFilter.from_dict(None).is_empty() - - def test_from_dict_empty_is_empty(self) -> None: - assert MaintenanceScopeFilter.from_dict({}).is_empty() - - def test_from_dict_ignores_absent_dimensions(self) -> None: - f = MaintenanceScopeFilter.from_dict({"origin": "claude-code-session"}) - assert f.origin == "claude-code-session" - assert f.session_ids is None - - -class TestPlannerHonorsFilter: - """The planner threads the filter onto the returned scope and narrows preview counts.""" - - def test_preview_attaches_filter_to_scope(self, tmp_path: Path) -> None: - config = _make_config(tmp_path) - scope_filter = MaintenanceScopeFilter(session_ids=("c1", "c2")) - op = preview_backfill(config, targets=("session_insights",), scope_filter=scope_filter) - assert op.scope is not None - assert op.scope.filter == scope_filter - - def test_preview_narrows_affected_rows_for_session_ids(self, tmp_path: Path) -> None: - from polylogue.maintenance.models import DerivedModelStatus - - config = _make_config(tmp_path) - # Patch the debt collector to advertise 1000 pending insights; - # a one-session filter must clamp that down to 1. - fake_status = DerivedModelStatus( - name="session_insights", - ready=False, - detail="1000 pending", - source_documents=1000, - materialized_documents=0, - stale_rows=1000, - missing_provenance_rows=0, - ) - - def _fake_collect(*_a: Any, **_kw: Any) -> dict[str, DerivedModelStatus]: - return {"session_insights": fake_status} - - def _fake_counts(_statuses: dict[str, DerivedModelStatus]) -> dict[str, int]: - return {"session_insights": 1000} - - with ( - patch("polylogue.storage.repair.collect_archive_debt_statuses_sync", _fake_collect), - patch("polylogue.storage.repair.preview_counts_from_archive_debt", _fake_counts), - ): - broad = preview_backfill(config, targets=("session_insights",)) - narrow = preview_backfill( - config, - targets=("session_insights",), - scope_filter=MaintenanceScopeFilter(session_ids=("only-one",)), - ) - - assert broad.affected_rows == 1000 - assert narrow.affected_rows == 1 - assert narrow.scope is not None - assert narrow.scope.filter.session_ids == ("only-one",) - - -class TestCrossSurfaceFilterParity: - """The same typed filter must serialize identically across CLI / daemon / MCP.""" - - def test_filter_serializes_identically_across_surfaces(self, tmp_path: Path) -> None: - operation = _example_operation_with_filter( - MaintenanceScopeFilter( - session_ids=("c1", "c2"), - origin="claude-code-session", - source_family="claude-code-session", - ) - ) - - # --- CLI --- - cli_payload = _capture_cli_preview(operation, tmp_path) - - # --- daemon (direct envelope, same as the HTTP handler emits) --- - daemon_payload = cast( - dict[str, Any], - envelope_from_operation(operation, origin="daemon", mode="preview").to_dict(), - ) - - # --- MCP --- - mcp_payload = _capture_mcp_preview(operation) - - cli_filter = cli_payload["scope"]["filter"] - daemon_filter = daemon_payload["scope"]["filter"] - mcp_filter = mcp_payload["scope"]["filter"] - - assert cli_filter == daemon_filter == mcp_filter - assert cli_filter["session_ids"] == ["c1", "c2"] - assert cli_filter["origin"] == "claude-code-session" - assert cli_filter["source_family"] == "claude-code-session" - - def test_daemon_http_parses_filter_body(self) -> None: - """``POST /api/maintenance/plan`` parses the typed-filter body fields. - - The daemon handler accepts both a nested ``{"scope":{"filter":{...}}}`` - envelope and a flat top-level shape; both round-trip through the - same :class:`MaintenanceScopeFilter`. - """ - from io import BytesIO - from typing import cast - - from tests.unit.daemon.test_maintenance_endpoints import MockHeaders, _make_handler - - captured: dict[str, Any] = {} - - def _capture( - config: Any, *, targets: tuple[str, ...], scope_filter: MaintenanceScopeFilter - ) -> BackfillOperation: - captured["targets"] = targets - captured["filter"] = scope_filter - return _example_operation_with_filter(scope_filter) - - body = { - "targets": ["session_insights"], - "session_ids": ["c1", "c2"], - "origin": "claude-code-session", - "source_family": "claude-code-session", - } - body_raw = json.dumps(body).encode("utf-8") - handler = _make_handler("/api/maintenance/plan") - cast(MockHeaders, handler.headers)._headers["Content-Length"] = str(len(body_raw)) - handler.rfile = BytesIO(body_raw) - - with ( - patch("polylogue.maintenance.planner.preview_backfill", side_effect=_capture), - patch.object(handler, "_send_json"), - ): - handler._handle_maintenance_plan() - - assert captured["filter"].session_ids == ("c1", "c2") - assert captured["filter"].origin == "claude-code-session" - assert captured["filter"].source_family == "claude-code-session" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -def _example_operation_with_filter(scope_filter: MaintenanceScopeFilter) -> BackfillOperation: - return BackfillOperation( - operation_id="op-1", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.PENDING, - scope=MaintenanceScope(targets=("session_insights",), filter=scope_filter), - ) - - -def _capture_cli_preview(operation: BackfillOperation, tmp_path: Path) -> dict[str, Any]: - archive = tmp_path / "archive" - render = tmp_path / "render" - archive.mkdir(parents=True, exist_ok=True) - render.mkdir(parents=True, exist_ok=True) - - runner = CliRunner() - config_obj = Config(archive_root=archive, render_root=render, sources=[]) - - with ( - patch("polylogue.cli.commands.maintenance._plan.preview_backfill", return_value=operation), - patch("polylogue.cli.commands.maintenance._plan.archive_root", return_value=archive), - patch("polylogue.cli.commands.maintenance._plan.render_root", return_value=render), - ): - result = runner.invoke( - maintenance_group, - [ - "plan", - "--target", - "session_insights", - "--session-id", - "c1", - "--session-id", - "c2", - "--origin", - "claude-code-session", - "--source-family", - "claude-code-session", - "--output-format", - "json", - ], - obj=config_obj, - ) - - assert result.exit_code == 0, result.output - payload: dict[str, Any] = json.loads(result.output) - return payload - - -def _capture_mcp_preview(operation: BackfillOperation) -> dict[str, Any]: - from polylogue.mcp.server import build_server - from tests.infra.mcp import ALL_CAPABILITIES - - server = build_server(capabilities=ALL_CAPABILITIES) - fn = server._tool_manager._tools["maintenance"].fn - - with patch("polylogue.maintenance.planner.preview_backfill", return_value=operation): - result = asyncio.run( - fn( - operation="preview", - session_ids=["c1", "c2"], - origin="claude-code-session", - source_family="claude-code-session", - ) - ) - payload: dict[str, Any] = json.loads(result) - return payload diff --git a/tests/unit/maintenance/test_scope_filter_envelope_contract.py b/tests/unit/maintenance/test_scope_filter_envelope_contract.py deleted file mode 100644 index 54fdf57148..0000000000 --- a/tests/unit/maintenance/test_scope_filter_envelope_contract.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Cross-surface envelope contract for the typed maintenance scope filter (#1303). - -One typed :class:`MaintenanceScopeFilter` must round-trip identically -through every surface — CLI ``polylogue ops maintenance plan`` flags, the -daemon ``POST /api/maintenance/plan`` JSON body, and MCP -``maintenance_preview`` typed parameters — and every surface must echo -it back as the same envelope JSON. The test pins: - -* CLI argv → planner ``scope_filter`` (every dimension) -* HTTP POST body → planner ``scope_filter`` (every dimension; nested - ``{"scope":{"filter":{...}}}`` envelope and flat top-level form both - accepted) -* MCP typed args → planner ``scope_filter`` (every dimension) -* All three surfaces serialize the same operation back to a byte-equal - ``scope.filter`` envelope payload. - -This is the cross-surface coherence assertion that prevents #1196 from -drifting if any one surface gains a new dimension without the other -two. -""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime, timezone -from io import BytesIO -from pathlib import Path -from typing import Any, cast -from unittest.mock import patch - -from click.testing import CliRunner - -from polylogue.cli.commands.maintenance import maintenance_group -from polylogue.config import Config -from polylogue.core.enums import OperationStatus -from polylogue.maintenance.envelope import envelope_from_operation -from polylogue.maintenance.planner import ( - BackfillKind, - BackfillOperation, - MaintenanceScope, -) -from polylogue.maintenance.scope import MaintenanceScopeFilter - -# --------------------------------------------------------------------------- -# Canonical full filter — covers every dimension in one payload -# --------------------------------------------------------------------------- - - -_SINCE = datetime(2026, 1, 1, tzinfo=timezone.utc) -_UNTIL = datetime(2026, 2, 1, tzinfo=timezone.utc) - - -def _canonical_filter() -> MaintenanceScopeFilter: - return MaintenanceScopeFilter( - session_ids=("c1", "c2"), - origin="claude-code-session", - source_family="claude-code-session", - source_root=Path("/data/claude"), - time_range=(_SINCE, _UNTIL), - failure_kind="ValidationError", - parser_version="v3", - ) - - -def _operation_for(scope_filter: MaintenanceScopeFilter) -> BackfillOperation: - return BackfillOperation( - operation_id="op-fixed", - kind=BackfillKind.DERIVED_REBUILD, - targets=("session_insights",), - status=OperationStatus.PENDING, - scope=MaintenanceScope(targets=("session_insights",), filter=scope_filter), - ) - - -def _make_config(tmp_path: Path) -> Config: - archive_root = tmp_path / "archive" - render_root = tmp_path / "render" - archive_root.mkdir(parents=True, exist_ok=True) - render_root.mkdir(parents=True, exist_ok=True) - return Config( - archive_root=archive_root, - render_root=render_root, - sources=[], - db_path=tmp_path / "archive.db", - ) - - -# --------------------------------------------------------------------------- -# CLI surface — argv to planner call to envelope -# --------------------------------------------------------------------------- - - -def _invoke_cli_plan(operation: BackfillOperation, tmp_path: Path) -> tuple[dict[str, Any], MaintenanceScopeFilter]: - """Run ``polylogue ops maintenance plan`` and return (envelope JSON, captured filter).""" - captured: dict[str, MaintenanceScopeFilter] = {} - - def _capture(_config: Any, *, targets: tuple[str, ...], scope_filter: MaintenanceScopeFilter) -> BackfillOperation: - captured["filter"] = scope_filter - return operation.__class__( - operation_id=operation.operation_id, - kind=operation.kind, - targets=operation.targets, - status=operation.status, - scope=MaintenanceScope(targets=targets, filter=scope_filter), - ) - - runner = CliRunner() - archive = tmp_path / "archive" - render = tmp_path / "render" - archive.mkdir(parents=True, exist_ok=True) - render.mkdir(parents=True, exist_ok=True) - config_obj = Config(archive_root=archive, render_root=render, sources=[]) - - with ( - patch("polylogue.cli.commands.maintenance._plan.preview_backfill", side_effect=_capture), - patch("polylogue.cli.commands.maintenance._plan.archive_root", return_value=archive), - patch("polylogue.cli.commands.maintenance._plan.render_root", return_value=render), - ): - result = runner.invoke( - maintenance_group, - [ - "plan", - "--target", - "session_insights", - "--session-id", - "c1", - "--session-id", - "c2", - "--origin", - "claude-code-session", - "--source-family", - "claude-code-session", - "--source-root", - "/data/claude", - "--since", - "2026-01-01T00:00:00Z", - "--until", - "2026-02-01T00:00:00Z", - "--failure-kind", - "ValidationError", - "--parser-version", - "v3", - "--output-format", - "json", - ], - obj=config_obj, - ) - - assert result.exit_code == 0, result.output - payload: dict[str, Any] = json.loads(result.output) - return payload, captured["filter"] - - -# --------------------------------------------------------------------------- -# HTTP surface — POST body to planner call to envelope -# --------------------------------------------------------------------------- - - -def _invoke_daemon_plan(body: dict[str, Any]) -> tuple[dict[str, Any], MaintenanceScopeFilter]: - """Drive ``_handle_maintenance_plan`` with ``body`` and return (envelope, filter).""" - from tests.unit.daemon.test_maintenance_endpoints import MockHeaders, _make_handler - - captured: dict[str, MaintenanceScopeFilter] = {} - - def _capture(_config: Any, *, targets: tuple[str, ...], scope_filter: MaintenanceScopeFilter) -> BackfillOperation: - captured["filter"] = scope_filter - return _operation_for(scope_filter) - - body_raw = json.dumps(body).encode("utf-8") - handler = _make_handler("/api/maintenance/plan") - cast(MockHeaders, handler.headers)._headers["Content-Length"] = str(len(body_raw)) - handler.rfile = BytesIO(body_raw) - - sent: dict[str, Any] = {} - - def _capture_send(status: Any, payload: Any) -> None: - sent["status"] = status - sent["payload"] = payload - - with ( - patch("polylogue.maintenance.planner.preview_backfill", side_effect=_capture), - patch.object(handler, "_send_json", side_effect=_capture_send), - ): - handler._handle_maintenance_plan() - - return cast(dict[str, Any], sent["payload"]), captured["filter"] - - -# --------------------------------------------------------------------------- -# MCP surface — typed args to planner call to envelope -# --------------------------------------------------------------------------- - - -def _invoke_mcp_preview() -> tuple[dict[str, Any], MaintenanceScopeFilter]: - """Drive ``maintenance(operation="preview")`` with the canonical filter args.""" - from polylogue.mcp.server import build_server - from tests.infra.mcp import ALL_CAPABILITIES - - captured: dict[str, MaintenanceScopeFilter] = {} - - def _capture(_config: Any, *, targets: tuple[str, ...], scope_filter: MaintenanceScopeFilter) -> BackfillOperation: - captured["filter"] = scope_filter - return _operation_for(scope_filter) - - server = build_server(capabilities=ALL_CAPABILITIES) - fn = server._tool_manager._tools["maintenance"].fn - - with patch("polylogue.maintenance.planner.preview_backfill", side_effect=_capture): - result = asyncio.run( - fn( - operation="preview", - targets=["session_insights"], - session_ids=["c1", "c2"], - origin="claude-code-session", - source_family="claude-code-session", - source_root="/data/claude", - since="2026-01-01T00:00:00Z", - until="2026-02-01T00:00:00Z", - failure_kind="ValidationError", - parser_version="v3", - ) - ) - - payload: dict[str, Any] = json.loads(result) - return payload, captured["filter"] - - -# --------------------------------------------------------------------------- -# Single-dimension parity tests — one assertion per scope dimension -# --------------------------------------------------------------------------- - - -class TestCLIArgsBuildScopeFilter: - """Each CLI flag round-trips into the matching scope-filter field.""" - - def test_every_dimension_reaches_the_planner(self, tmp_path: Path) -> None: - _, captured = _invoke_cli_plan(_operation_for(_canonical_filter()), tmp_path) - assert captured == _canonical_filter() - - -class TestDaemonBodyBuildsScopeFilter: - """HTTP body fields round-trip into the matching scope-filter field.""" - - def test_flat_body_every_dimension_reaches_the_planner(self) -> None: - body = { - "targets": ["session_insights"], - "session_ids": ["c1", "c2"], - "origin": "claude-code-session", - "source_family": "claude-code-session", - "source_root": "/data/claude", - "time_range": [ - "2026-01-01T00:00:00+00:00", - "2026-02-01T00:00:00+00:00", - ], - "failure_kind": "ValidationError", - "parser_version": "v3", - } - _, captured = _invoke_daemon_plan(body) - assert captured == _canonical_filter() - - def test_nested_scope_filter_body_is_accepted(self) -> None: - """``{"scope":{"filter":{...}}}`` body shape is honored too. - - The daemon HTTP handler accepts both the flat top-level shape and - the nested envelope shape, so a client that echoes a previous - envelope's ``scope.filter`` payload still drives the planner - with the same filter. - """ - body = { - "targets": ["session_insights"], - "scope": {"filter": _canonical_filter().to_dict()}, - } - _, captured = _invoke_daemon_plan(body) - assert captured == _canonical_filter() - - -class TestMCPArgsBuildScopeFilter: - """MCP typed parameters round-trip into the matching scope-filter field.""" - - def test_every_dimension_reaches_the_planner(self) -> None: - _, captured = _invoke_mcp_preview() - assert captured == _canonical_filter() - - -# --------------------------------------------------------------------------- -# Cross-surface envelope parity -# --------------------------------------------------------------------------- - - -class TestEnvelopeFilterParity: - """All three surfaces emit the same ``scope.filter`` envelope JSON.""" - - def test_envelope_filters_match_across_surfaces(self, tmp_path: Path) -> None: - # Same operation, three different surfaces. The envelope is the - # contract — every surface must produce a byte-equal ``scope.filter``. - operation = _operation_for(_canonical_filter()) - - cli_payload, cli_filter = _invoke_cli_plan(operation, tmp_path) - daemon_payload, daemon_filter = _invoke_daemon_plan( - { - "targets": ["session_insights"], - **_canonical_filter().to_dict(), - } - ) - mcp_payload, mcp_filter = _invoke_mcp_preview() - - # 1. Every surface reconstructed the same typed filter. - assert cli_filter == daemon_filter == mcp_filter == _canonical_filter() - - # 2. Every surface echoed the same envelope ``scope.filter`` payload. - cli_envelope_filter = cli_payload["scope"]["filter"] - daemon_envelope_filter = daemon_payload["scope"]["filter"] - mcp_envelope_filter = mcp_payload["scope"]["filter"] - assert cli_envelope_filter == daemon_envelope_filter == mcp_envelope_filter - - # 3. That envelope payload is exactly the canonical filter's - # JSON-shape — no surface added or dropped dimensions. - assert cli_envelope_filter == _canonical_filter().to_dict() - - def test_envelope_filter_round_trips_back_to_typed_filter(self, tmp_path: Path) -> None: - """Echoing the envelope ``scope.filter`` payload reconstructs the typed filter.""" - cli_payload, _ = _invoke_cli_plan(_operation_for(_canonical_filter()), tmp_path) - recovered = MaintenanceScopeFilter.from_dict(cli_payload["scope"]["filter"]) - assert recovered == _canonical_filter() - - -# --------------------------------------------------------------------------- -# Empty-filter parity — the default "no narrowing" case -# --------------------------------------------------------------------------- - - -class TestEmptyFilterParity: - """An empty filter must serialize identically across surfaces too.""" - - def test_envelope_from_empty_operation_matches_canonical_empty(self) -> None: - envelope = cast( - dict[str, Any], - envelope_from_operation( - _operation_for(MaintenanceScopeFilter()), - origin="cli", - mode="preview", - ).to_dict(), - ) - assert envelope["scope"]["filter"] == MaintenanceScopeFilter().to_dict() diff --git a/tests/unit/maintenance/test_scope_filter_roundtrip.py b/tests/unit/maintenance/test_scope_filter_roundtrip.py deleted file mode 100644 index d61aa6188b..0000000000 --- a/tests/unit/maintenance/test_scope_filter_roundtrip.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Round-trip contract for :class:`MaintenanceScopeFilter` (#1303). - -Pins the typed-filter round-trip independent of any surface: - -* ``to_dict()`` always yields a JSON-shaped dict that lists every known - dimension exactly once (so absent dimensions stay explicitly - ``None`` instead of being silently dropped); -* ``from_dict(to_dict(filter))`` is the identity for every dimension - combination — verified both by example and by a Hypothesis property - that draws across all eight dimensions; -* ``None`` and ``{}`` both rehydrate to the canonical empty filter so - surfaces that omit the field do not desynchronize from surfaces that - send an empty body. - -Companion to :mod:`tests.unit.maintenance.test_scope_filter` (which -pins the model's shape and frozen-ness) and -:mod:`tests.unit.maintenance.test_scope_filter_envelope_contract` -(which pins cross-surface parity). -""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import pytest -from hypothesis import HealthCheck, given, settings -from hypothesis import strategies as st - -from polylogue.maintenance.scope import MaintenanceScopeFilter - -_EXPECTED_DIMENSIONS = frozenset( - { - "session_ids", - "origin", - "source_family", - "source_root", - "time_range", - "failure_kind", - "parser_version", - } -) - - -class TestScopeFilterDictShape: - """``to_dict`` is byte-stable and exhaustive.""" - - def test_to_dict_lists_every_dimension_for_empty_filter(self) -> None: - payload = MaintenanceScopeFilter().to_dict() - assert set(payload.keys()) == _EXPECTED_DIMENSIONS - # Every absent dimension is explicit ``None`` rather than missing. - for dim in _EXPECTED_DIMENSIONS: - assert payload[dim] is None, f"{dim} should serialize as None when unset" - - def test_to_dict_lists_every_dimension_for_populated_filter(self) -> None: - full = MaintenanceScopeFilter( - session_ids=("c1", "c2"), - origin="claude-code-session", - source_family="claude-code-session", - source_root=Path("/data/claude"), - time_range=( - datetime(2026, 1, 1, tzinfo=timezone.utc), - datetime(2026, 2, 1, tzinfo=timezone.utc), - ), - failure_kind="ValidationError", - parser_version="v3", - ) - payload = full.to_dict() - assert set(payload.keys()) == _EXPECTED_DIMENSIONS - - def test_to_dict_is_json_serializable(self) -> None: - """``mode='json'`` must coerce Path/datetime/tuple to JSON primitives. - - Surfaces serialize the payload straight through :func:`json.dumps` - without a custom encoder — leaking a ``Path`` or ``datetime`` here - would crash the daemon HTTP response or the MCP envelope. - """ - full = MaintenanceScopeFilter( - session_ids=("c1",), - source_root=Path("/data"), - time_range=( - datetime(2026, 1, 1, tzinfo=timezone.utc), - datetime(2026, 2, 1, tzinfo=timezone.utc), - ), - ) - text = json.dumps(full.to_dict()) - # Round-trip via JSON must not lose information. - rehydrated = MaintenanceScopeFilter.from_dict(json.loads(text)) - assert rehydrated == full - - -class TestScopeFilterRoundTrip: - """from_dict(to_dict(f)) == f for every dimension combination.""" - - @pytest.mark.parametrize( - "filter_kwargs", - [ - {}, - {"session_ids": ("c1",)}, - {"session_ids": ("c1", "c2", "c3")}, - {"origin": "claude-code-session"}, - {"source_family": "claude-code-session"}, - {"source_root": Path("/data/claude")}, - { - "time_range": ( - datetime(2026, 1, 1, tzinfo=timezone.utc), - datetime(2026, 2, 1, tzinfo=timezone.utc), - ) - }, - {"failure_kind": "ValidationError"}, - {"parser_version": "v3"}, - { - "session_ids": ("c1", "c2"), - "origin": "claude-code-session", - "source_family": "claude-code-session", - "source_root": Path("/data"), - "time_range": ( - datetime(2026, 1, 1, tzinfo=timezone.utc), - datetime(2026, 2, 1, tzinfo=timezone.utc), - ), - "failure_kind": "decode-error", - "parser_version": "v3", - }, - ], - ) - def test_round_trip_preserves_filter(self, filter_kwargs: dict[str, Any]) -> None: - original = MaintenanceScopeFilter(**filter_kwargs) - recovered = MaintenanceScopeFilter.from_dict(original.to_dict()) - assert recovered == original - - def test_double_round_trip_is_idempotent(self) -> None: - original = MaintenanceScopeFilter( - session_ids=("c1",), - origin="claude-code-session", - source_root=Path("/data"), - time_range=( - datetime(2026, 1, 1, tzinfo=timezone.utc), - datetime(2026, 2, 1, tzinfo=timezone.utc), - ), - ) - once = MaintenanceScopeFilter.from_dict(original.to_dict()) - twice = MaintenanceScopeFilter.from_dict(once.to_dict()) - assert original == once == twice - # The serialized payload itself is byte-stable, too. - assert original.to_dict() == once.to_dict() == twice.to_dict() - - def test_from_dict_none_is_empty_filter(self) -> None: - assert MaintenanceScopeFilter.from_dict(None) == MaintenanceScopeFilter() - - def test_from_dict_empty_dict_is_empty_filter(self) -> None: - assert MaintenanceScopeFilter.from_dict({}) == MaintenanceScopeFilter() - - def test_from_dict_with_explicit_none_dimensions_is_empty(self) -> None: - """A payload that names every dimension as ``None`` is the empty filter. - - This is the shape ``to_dict()`` emits, so the empty-filter - round-trip must not depend on the caller pruning ``None`` keys. - """ - all_none = dict.fromkeys(_EXPECTED_DIMENSIONS) - assert MaintenanceScopeFilter.from_dict(all_none) == MaintenanceScopeFilter() - - -# --------------------------------------------------------------------------- -# Hypothesis property: round-trip identity across the whole dimension space -# --------------------------------------------------------------------------- - - -_ASCII_TEXT = st.text( - alphabet=st.characters(min_codepoint=0x20, max_codepoint=0x7E), - min_size=1, - max_size=24, -) - - -@st.composite -def _scope_filter_kwargs(draw: Any) -> dict[str, Any]: - """Draw kwargs for an arbitrary :class:`MaintenanceScopeFilter`. - - Each dimension is independently present-or-absent, so the property - explores the full 2^8 presence lattice rather than only fully-populated - or fully-empty filters. - """ - kwargs: dict[str, Any] = {} - if draw(st.booleans()): - kwargs["session_ids"] = tuple(draw(st.lists(_ASCII_TEXT, min_size=1, max_size=4, unique=True))) - if draw(st.booleans()): - kwargs["origin"] = draw(_ASCII_TEXT) - if draw(st.booleans()): - kwargs["source_family"] = draw(_ASCII_TEXT) - if draw(st.booleans()): - kwargs["source_root"] = Path(draw(_ASCII_TEXT)) - if draw(st.booleans()): - # Two distinct tz-aware datetimes so the (since, until) ordering - # is meaningful but we never have to worry about naive timestamps. - since = draw( - st.datetimes( - min_value=datetime(2020, 1, 1), - max_value=datetime(2030, 12, 31), - timezones=st.just(timezone.utc), - ) - ) - until = draw( - st.datetimes( - min_value=datetime(2020, 1, 1), - max_value=datetime(2030, 12, 31), - timezones=st.just(timezone.utc), - ) - ) - kwargs["time_range"] = (since, until) - if draw(st.booleans()): - kwargs["failure_kind"] = draw(_ASCII_TEXT) - if draw(st.booleans()): - kwargs["parser_version"] = draw(_ASCII_TEXT) - return kwargs - - -@given(_scope_filter_kwargs()) -@settings( - max_examples=200, - deadline=None, - suppress_health_check=[HealthCheck.function_scoped_fixture], -) -def test_round_trip_property(kwargs: dict[str, Any]) -> None: - """Property: any valid filter round-trips through ``to_dict``/``from_dict``.""" - original = MaintenanceScopeFilter(**kwargs) - recovered = MaintenanceScopeFilter.from_dict(original.to_dict()) - assert recovered == original - # And the serialized payload is itself byte-stable across one round trip. - assert original.to_dict() == recovered.to_dict() diff --git a/tests/unit/maintenance/test_targets.py b/tests/unit/maintenance/test_targets.py deleted file mode 100644 index 3c612abf18..0000000000 --- a/tests/unit/maintenance/test_targets.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Catalog-owns-replay contract tests (polylogue-71ey). - -Two concrete bugs motivated this file: - -1. The canonical maintenance target catalog - (:mod:`polylogue.maintenance.targets`) advertised seven targets, but - the resumable replay executor kept a private, hand-maintained - dispatch table that omitted ``superseded_raw_snapshots``. The - generated CLI accepted the target (``click.Choice`` is built from the - catalog) and then failed at runtime with - :class:`~polylogue.maintenance.replay.UnsupportedReplayTargetError`. -2. ``polylogue ops maintenance run`` with no ``--target`` (the - documented "no scope — full inventory" targetless path) resolved to - zero targets and returned ``status=failed`` with exit code 0. - -``TestCatalogReplayEquality`` proves every catalog target is either -replay-capable (with a real handler in -:data:`polylogue.storage.repair.REPAIR_HANDLERS`) or explicitly -declared non-replayable with a surface-visible reason -- derived from -the catalog and the real handler dict, not a hardcoded list mirroring -either. ``TestRealAdapterTargetlessRunAll`` and -``TestExplicitSupersededRawSnapshotsRoute`` exercise the three real -adapters (CLI, MCP, HTTP) end to end rather than rendering a prebuilt -envelope. ``TestFailedEnvelopeSurfaces`` pins AC 4: a failed maintenance -envelope is a non-zero CLI exit and a typed HTTP/MCP failure, not a -200-shaped success body. -""" - -from __future__ import annotations - -import asyncio -import io -import json -from email.message import Message -from http import HTTPStatus -from pathlib import Path -from typing import cast - -import pytest -from click.testing import CliRunner - -from polylogue.cli.click_app import cli -from polylogue.daemon.http import DaemonAPIHandler -from polylogue.maintenance.targets import build_maintenance_target_catalog -from polylogue.storage import repair as repair_module - - -def _extract_json_envelope(output: str) -> dict[str, object]: - """Pull the trailing ``--output-format json`` envelope out of CLI output. - - ``CliRunner`` (Click 8.2+) merges stdout and stderr into one stream, so - ``result.output`` also carries the ``--dry-run`` progress lines this - command writes to stderr before the JSON envelope. Those lines never - contain ``{``, so decoding from the first ``{`` in the stream isolates - the JSON payload. - """ - decoder = json.JSONDecoder() - payload, _ = decoder.raw_decode(output, output.index("{")) - assert isinstance(payload, dict) - return cast("dict[str, object]", payload) - - -def _targets(payload: dict[str, object]) -> set[str]: - return set(cast("list[str]", payload["targets"])) - - -def _always_raises(_config: object, _dry_run: bool) -> object: - raise RuntimeError("simulated repair failure (polylogue-71ey AC 4 fixture)") - - -def _http_headers(body: bytes) -> Message: - headers = Message() - headers["Content-Length"] = str(len(body)) - return headers - - -def _build_run_handler(body: bytes) -> tuple[DaemonAPIHandler, list[tuple[object, dict[str, object]]]]: - """Construct a bare ``DaemonAPIHandler`` and drive its real POST body. - - Mirrors the pattern in ``tests/unit/daemon/test_http_write_coordination.py``: - ``object.__new__`` skips socket setup, and ``_send_json``/``_send_error`` - are replaced with recording stubs so the real ``_handle_maintenance_run`` - body runs to completion without a live connection. - """ - handler = object.__new__(DaemonAPIHandler) - handler.headers = _http_headers(body) - handler.rfile = io.BytesIO(body) - calls: list[tuple[object, dict[str, object]]] = [] - handler._send_json = lambda status, payload, **_kw: calls.append((status, payload)) # type: ignore[method-assign] - handler._send_error = lambda *a, **kw: calls.append(("error", {"args": a, "kwargs": kw})) # type: ignore[method-assign] - return handler, calls - - -# --------------------------------------------------------------------------- -# AC 1: catalog equality -- every target is replayable-with-handler or -# explicitly non-replayable-with-reason. -# --------------------------------------------------------------------------- - - -class TestCatalogReplayEquality: - def test_spent_message_type_backfill_has_no_generic_repair_route(self) -> None: - catalog = build_maintenance_target_catalog() - assert catalog.resolve_name("message_type_backfill") is None - assert "message_type_backfill" not in repair_module.REPAIR_HANDLERS - assert "message_type_backfill" not in repair_module.PREVIEW_HANDLERS - - def test_cli_rejects_spent_message_type_target(self, cli_runner: CliRunner) -> None: - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "run-preview", - "--target", - "message_type_backfill", - ], - ) - assert result.exit_code != 0 - assert "Invalid value for '--target'" in result.output - - def test_every_replayable_target_has_a_real_handler(self) -> None: - """A target claiming ``replayable=True`` must have a real handler. - - Deleting a :data:`polylogue.storage.repair.REPAIR_HANDLERS` entry - for any replayable catalog target must fail this test -- it is - derived from the live catalog and the live handler dict, not a - name list copied from either. - """ - catalog = build_maintenance_target_catalog() - missing_handlers = [ - spec.name for spec in catalog.specs if spec.replayable and spec.name not in repair_module.REPAIR_HANDLERS - ] - assert missing_handlers == [], ( - f"catalog declares these targets replayable but REPAIR_HANDLERS has no entry for them: {missing_handlers}" - ) - - def test_every_non_replayable_target_has_a_visible_reason(self) -> None: - catalog = build_maintenance_target_catalog() - unexplained = [spec.name for spec in catalog.specs if not spec.replayable and not spec.non_replayable_reason] - assert unexplained == [], f"non-replayable targets missing a surface-visible reason: {unexplained}" - - def test_non_replayable_reason_surfaces_in_to_dict(self) -> None: - catalog = build_maintenance_target_catalog() - for spec in catalog.specs: - payload = spec.to_dict() - assert payload["replayable"] == spec.replayable - assert payload["non_replayable_reason"] == spec.non_replayable_reason - - def test_supported_replay_targets_matches_catalog_replayable_set(self) -> None: - """``supported_replay_targets()`` derives from the catalog + REPAIR_HANDLERS. - - This is the anti-vacuity check for bug 1: it fails if the two - ever diverge again, without hardcoding either side's target - names. - """ - from polylogue.maintenance.replay import supported_replay_targets - - catalog = build_maintenance_target_catalog() - expected = { - spec.name for spec in catalog.specs if spec.replayable and spec.name in repair_module.REPAIR_HANDLERS - } - assert set(supported_replay_targets()) == expected - - def test_superseded_raw_snapshots_is_declared_replayable(self) -> None: - """Regression pin for bug 1: this target was silently excluded before.""" - catalog = build_maintenance_target_catalog() - spec = catalog.resolve_name("superseded_raw_snapshots") - assert spec is not None - assert spec.replayable is True - assert "superseded_raw_snapshots" in repair_module.REPAIR_HANDLERS - - -# --------------------------------------------------------------------------- -# AC 1 (route proof) + AC 3 (CLI real route): explicit --target -# superseded_raw_snapshots succeeds through the real CLI, not a -# hand-rolled call into execute_replay. -# --------------------------------------------------------------------------- - - -class TestExplicitSupersededRawSnapshotsRoute: - def test_cli_run_explicit_target_succeeds(self, cli_workspace: dict[str, Path], cli_runner: CliRunner) -> None: - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "run-preview", - "--target", - "superseded_raw_snapshots", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert result.exit_code == 0, result.output - payload = _extract_json_envelope(result.output) - assert payload["status"] == "completed" - assert _targets(payload) == {"superseded_raw_snapshots"} - results = cast("list[dict[str, object]]", payload["results"]) - assert results[0]["name"] == "superseded_raw_snapshots" - assert results[0]["success"] is True - - -# --------------------------------------------------------------------------- -# AC 2 + AC 3: targetless run-all through the three real adapters. -# --------------------------------------------------------------------------- - - -def _catalog_names() -> frozenset[str]: - return frozenset(build_maintenance_target_catalog().names()) - - -class TestRealAdapterTargetlessRunAll: - """``--dry-run`` with no ``--target`` must expand to the documented - run-all set (every catalog target) and succeed, through each of the - three real request adapters -- not a synthetic ``BackfillOperation`` - rendered through ``envelope_from_operation`` in isolation. - """ - - def test_cli_targetless_dry_run_expands_to_catalog_and_succeeds( - self, cli_workspace: dict[str, Path], cli_runner: CliRunner - ) -> None: - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "run-preview", "--output-format", "json"], - catch_exceptions=False, - ) - assert result.exit_code == 0, result.output - payload = _extract_json_envelope(result.output) - assert payload["status"] == "completed" - assert _targets(payload) == _catalog_names() - - def test_mcp_targetless_execute_dry_run_expands_to_catalog_and_succeeds( - self, cli_workspace: dict[str, Path] - ) -> None: - from polylogue.mcp.declarations.models import MCPCapabilities - from polylogue.mcp.server import build_server - - server = build_server(capabilities=MCPCapabilities(maintenance=True)) - fn = server._tool_manager._tools["maintenance"].fn - - result = asyncio.run(fn(operation="execute", dry_run=True)) - payload = cast("dict[str, object]", json.loads(result)) - assert "ok" not in payload # the typed error envelope shape, absent on success - assert payload["status"] == "completed" - assert _targets(payload) == _catalog_names() - - def test_http_targetless_run_dry_run_expands_to_catalog_and_succeeds(self, cli_workspace: dict[str, Path]) -> None: - body = json.dumps({"dry_run": True}).encode("utf-8") - handler, calls = _build_run_handler(body) - - handler._handle_maintenance_run() - - assert len(calls) == 1 - status, payload = calls[0] - assert status == HTTPStatus.OK - assert payload["status"] == "completed" - assert _targets(payload) == _catalog_names() - - def test_three_adapters_agree_on_targetless_target_set( - self, cli_workspace: dict[str, Path], cli_runner: CliRunner - ) -> None: - """The one property the three real adapters must share: what - "no explicit target" resolves to. Deleting the shared - ``MaintenanceTargetCatalog.resolve_or_default`` default-expansion - behavior (reverting to the old "empty targets -> failed" path in - any of the three call sites) makes this test fail. - """ - cli_result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "run-preview", "--output-format", "json"], - catch_exceptions=False, - ) - cli_targets = _targets(_extract_json_envelope(cli_result.output)) - - from polylogue.mcp.declarations.models import MCPCapabilities - from polylogue.mcp.server import build_server - - server = build_server(capabilities=MCPCapabilities(maintenance=True)) - fn = server._tool_manager._tools["maintenance"].fn - mcp_payload = cast("dict[str, object]", json.loads(asyncio.run(fn(operation="execute", dry_run=True)))) - mcp_targets = _targets(mcp_payload) - - body = json.dumps({"dry_run": True}).encode("utf-8") - handler, calls = _build_run_handler(body) - handler._handle_maintenance_run() - assert calls[0][0] == HTTPStatus.OK - http_targets = _targets(calls[0][1]) - - assert cli_targets == mcp_targets == http_targets == _catalog_names() - - -# --------------------------------------------------------------------------- -# AC 4: a failed maintenance envelope is a non-zero CLI exit and a typed -# HTTP/MCP failure -- not a 200/exit-0 body that happens to say "failed". -# --------------------------------------------------------------------------- - - -class TestFailedEnvelopeSurfaces: - """Drive a deterministic failure (the ``session_insights`` handler - raises) through each real adapter and assert the failure is visible - without parsing the response body -- exit code / HTTP status / - typed MCP error, not merely a ``"status": "failed"`` string a - caller has to notice. - - ``dry_run`` must stay ``False`` here: the offline daemon-PID guard - short-circuits to "no block" whenever ``dry_run=True`` - (``offline_maintenance_block_reason``), so a dry-run request cannot - exercise this failure path -- only a raising handler can. - """ - - def test_cli_run_exits_non_zero_on_failure( - self, cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "session_insights", _always_raises) - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "run", - "--target", - "session_insights", - "--output-format", - "json", - ], - ) - payload = _extract_json_envelope(result.output) - assert payload["status"] == "failed" - assert result.exit_code != 0 - - def test_http_run_returns_422_on_failure( - self, cli_workspace: dict[str, Path], monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "session_insights", _always_raises) - body = json.dumps({"targets": ["session_insights"]}).encode("utf-8") - handler, calls = _build_run_handler(body) - - handler._handle_maintenance_run() - - assert len(calls) == 1 - status, payload = calls[0] - assert status == HTTPStatus.UNPROCESSABLE_ENTITY - assert payload["status"] == "failed" - - def test_mcp_execute_returns_typed_error_on_failure( - self, cli_workspace: dict[str, Path], monkeypatch: pytest.MonkeyPatch - ) -> None: - monkeypatch.setitem(repair_module.REPAIR_HANDLERS, "session_insights", _always_raises) - from polylogue.mcp.declarations.models import MCPCapabilities - from polylogue.mcp.server import build_server - - server = build_server(capabilities=MCPCapabilities(maintenance=True)) - fn = server._tool_manager._tools["maintenance"].fn - - # confirm=True is required for a non-dry-run execute (the MCP - # maintenance tool's confirm safety guard); without it the call never - # reaches the repair dispatch this test means to exercise and instead - # returns an unrelated "Safety guard: set confirm=true..." error. - result = asyncio.run(fn(operation="execute", targets=["session_insights"], confirm=True)) - payload = json.loads(result) - assert payload["ok"] is False - assert payload["code"] == "maintenance_execute_failed" diff --git a/tests/unit/storage/test_empty_session_repair_provenance.py b/tests/unit/storage/test_empty_session_repair_provenance.py deleted file mode 100644 index 5b01d7cf56..0000000000 --- a/tests/unit/storage/test_empty_session_repair_provenance.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Empty-session repair must delete positively-classified debris, never a -session that is merely message-less. - -History (polylogue-ne6k): the original ``repair_empty_sessions`` predicate was -a blanket ``NOT EXISTS (messages)`` join, which cannot distinguish corruption -debris from a session that is legitimately empty -- the 2026-07-22 -hook-inflation postmortem explicitly chose to RETAIN ~832 such sessions after -de-inflation. - -A first fix attempt tried ``raw_id IS NULL`` as the discriminator ("no -acquired bytes behind it = illegitimate"). That was tried and REFUTED: measured -on the live archive, all 5,257 message-less sessions carry a non-empty -``raw_id`` -- 4,945 of them are ``.meta`` sidecar phantoms that were -genuinely acquired (the sidecar file really was read), so "acquisition -happened" cannot separate phantoms from legitimate stubs. - -The real discriminator is WHAT THE ACQUIRED ARTIFACT IS, not whether bytes -were acquired: re-run each candidate's raw bytes through the same -``classify_artifact``/``inspect_raw_artifact`` pipeline live ingest uses, and -only delete the ones that pipeline still refuses to admit as a session. This -mirrors the ``looks_like_code`` fix in -``sources/parsers/claude/code_detection.py`` (polylogue-9ykn/gvgi): a genuine -positive marker is required, never a location- or absence-based guess. -""" - -from __future__ import annotations - -import sqlite3 -from collections.abc import Iterator -from pathlib import Path - -import pytest - -from polylogue.storage.blob_store import BlobStore, reset_blob_store -from polylogue.storage.repair import _empty_session_debris_session_ids, count_empty_sessions_sync -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - -_ORIGIN = "claude-code-session" - - -@pytest.fixture -def archive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: - """A real split source.db/index.db pair plus a blob store, all under tmp_path.""" - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - initialize_archive_database(tmp_path / "index.db", ArchiveTier.INDEX) - blob_root = tmp_path / "blob" - monkeypatch.setattr("polylogue.paths.blob_store_root", lambda: blob_root) - monkeypatch.setattr("polylogue.storage.blob_store.blob_store_root", lambda: blob_root, raising=False) - reset_blob_store() - yield tmp_path - reset_blob_store() - - -def _insert_raw_session( - conn: sqlite3.Connection, - *, - raw_id: str, - native_id: str, - source_path: str, - blob_size: int, -) -> None: - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, ?, ?, ?, 0, ?, ?, 1) - """, - (raw_id, _ORIGIN, native_id, source_path, bytes.fromhex(raw_id), blob_size), - ) - - -def _insert_session(conn: sqlite3.Connection, *, native_id: str, raw_id: str | None) -> str: - conn.execute( - "INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) VALUES (?, ?, ?, ?, ?)", - (native_id, _ORIGIN, raw_id, "Test", bytes(32)), - ) - row = conn.execute( - "SELECT session_id FROM sessions WHERE native_id = ? AND origin = ?", - (native_id, _ORIGIN), - ).fetchone() - return str(row[0]) - - -def _insert_message( - conn: sqlite3.Connection, - *, - session_id: str, - native_id: str, - position: int = 0, - word_count: int = 1, -) -> None: - conn.execute( - "INSERT INTO messages (session_id, native_id, position, role, word_count, content_hash) " - "VALUES (?, ?, ?, 'user', ?, ?)", - (session_id, native_id, position, word_count, bytes(32)), - ) - - -def _update_session_word_count(conn: sqlite3.Connection, *, session_id: str, word_count: int) -> None: - conn.execute( - "UPDATE sessions SET word_count = ? WHERE session_id = ?", - (word_count, session_id), - ) - - -def _seed(archive: Path) -> sqlite3.Connection: - """Seed one of every candidate shape and return an open index.db connection. - - - ``legit-empty``: raw bytes the current classifier still admits as a - session (a genuine Claude Code record with a ``sessionId`` marker) -- - the shape the hook-inflation postmortem retained ~832 of. - - ``phantom``: raw bytes an ``agent-*.meta.json`` sidecar path -- the - 4,945-row phantom class that is genuinely debris. - - ``no-provenance``: no ``raw_id`` at all -- no evidence either way, so it - must be retained, not treated as debris by default. - - ``healthy``: has a message, excluded regardless of provenance. - - ``all-empty-content-phantom``: many message rows (like a real - transcript), but every one carries zero words, backed by a - relationship-index-shaped raw artifact under an ``analysis/`` - directory (polylogue-21qj's ``conversation_relationships.jsonl`` - shape) -- debris, even though it is not literally message-less. - - ``all-empty-content-legit``: the same "many messages, all zero words" - shape, but backed by a genuine Claude Code record -- must be retained, - proving the broadened candidate query does not sweep in a legitimate - all-tool-use session with no text turns. - """ - store = BlobStore(archive / "blob") - legit_raw_id, legit_size = store.write_from_bytes(b'{"type":"summary","sessionId":"legit-empty-1"}\n') - phantom_raw_id, phantom_size = store.write_from_bytes(b'{"agentType":"general-purpose"}') - relationship_index_bytes = ( - b'{"conversation": "conv-1", "parent": "p-1", "child": "c-1", "type": "assistant", ' - b'"timestamp": "2026-05-01T00:00:00.000Z"}\n' - ) - relationship_index_raw_id, relationship_index_size = store.write_from_bytes(relationship_index_bytes) - legit_transcript_bytes = b'{"type":"summary","sessionId":"legit-all-empty-1"}\n' - legit_transcript_raw_id, legit_transcript_size = store.write_from_bytes(legit_transcript_bytes) - - with sqlite3.connect(archive / "source.db") as source_conn: - _insert_raw_session( - source_conn, - raw_id=legit_raw_id, - native_id="legit-empty", - source_path="conversation.jsonl", - blob_size=legit_size, - ) - _insert_raw_session( - source_conn, - raw_id=phantom_raw_id, - native_id="phantom", - source_path="agent-1234.meta.json", - blob_size=phantom_size, - ) - _insert_raw_session( - source_conn, - raw_id=relationship_index_raw_id, - native_id="conversation_relationships", - source_path="analysis/index/conversation_relationships.jsonl", - blob_size=relationship_index_size, - ) - _insert_raw_session( - source_conn, - raw_id=legit_transcript_raw_id, - native_id="legit-all-empty", - source_path="conversation-all-empty.jsonl", - blob_size=legit_transcript_size, - ) - source_conn.commit() - - conn = sqlite3.connect(archive / "index.db") - conn.row_factory = sqlite3.Row - _insert_session(conn, native_id="legit-empty", raw_id=legit_raw_id) - _insert_session(conn, native_id="phantom", raw_id=phantom_raw_id) - _insert_session(conn, native_id="no-provenance", raw_id=None) - healthy_session_id = _insert_session(conn, native_id="healthy", raw_id=None) - _insert_message(conn, session_id=healthy_session_id, native_id="m1") - - phantom_many_id = _insert_session(conn, native_id="conversation_relationships", raw_id=relationship_index_raw_id) - for index in range(3): - _insert_message(conn, session_id=phantom_many_id, native_id=f"cr-{index}", position=index, word_count=0) - _update_session_word_count(conn, session_id=phantom_many_id, word_count=0) - - legit_many_id = _insert_session(conn, native_id="legit-all-empty", raw_id=legit_transcript_raw_id) - for index in range(3): - _insert_message(conn, session_id=legit_many_id, native_id=f"le-{index}", position=index, word_count=0) - _update_session_word_count(conn, session_id=legit_many_id, word_count=0) - - conn.commit() - return conn - - -def test_only_the_positively_refused_artifact_counts_as_debris(archive: Path) -> None: - conn = _seed(archive) - try: - # "phantom" (message-less) and "conversation_relationships" (many - # messages, all zero words) -- both positively refused by the - # current classifier. "legit-empty" and "legit-all-empty" are the - # sibling shapes backed by a genuine record and must be excluded. - assert count_empty_sessions_sync(conn) == 2 - finally: - conn.close() - - -def test_all_empty_content_session_with_phantom_raw_counts_as_debris(archive: Path) -> None: - """polylogue-21qj: a session with real message rows that all carry zero - words (the ``conversation_relationships`` shape -- 96,748 message rows on - the live archive, none with any block/word content) must be treated as - debris when its raw artifact is a relationship-index-shaped, ``analysis/`` - -directory file that the current classifier positively refuses. The prior - predicate (``NOT EXISTS messages``) could never see this session at all, - since it is not literally message-less. - """ - conn = _seed(archive) - try: - debris_ids = _empty_session_debris_session_ids(conn) - assert "claude-code-session:conversation_relationships" in debris_ids - finally: - conn.close() - - -def test_all_empty_content_session_with_legit_raw_is_retained(archive: Path) -> None: - """Sibling of the test above: the identical 'many messages, all zero - words' shape must NOT become debris when its raw artifact is a genuine - Claude Code record (e.g. an all-tool-use session with no text turns) -- - the widened candidate query only ever proposes candidates; the classifier - gate is what actually decides, and it must still retain a legitimate - zero-word session. - """ - conn = _seed(archive) - try: - debris_ids = _empty_session_debris_session_ids(conn) - assert "claude-code-session:legit-all-empty" not in debris_ids - finally: - conn.close() - - -def test_blanket_predicate_would_have_swept_up_legitimate_sessions(archive: Path) -> None: - """Pin the defect itself: a revert to the old blanket predicate must fail this.""" - conn = _seed(archive) - try: - blanket = int( - conn.execute( - "SELECT COUNT(*) FROM sessions s " - "WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.session_id)" - ).fetchone()[0] - ) - # The blanket predicate sweeps in the legitimately-empty session and - # the no-provenance session too -- three total, not two -- and it - # cannot see "conversation_relationships"/"legit-all-empty" at all - # (they are not message-less). - assert blanket == 3 - assert count_empty_sessions_sync(conn) == 2, ( - "classifier-aware count must exclude the legitimate and no-provenance sessions" - ) - finally: - conn.close() - - -def test_raw_id_is_null_predicate_would_have_missed_the_phantom(archive: Path) -> None: - """Pin the other refuted defect (polylogue-ne6k correction): the phantom - row carries a non-empty raw_id, so 'debris = raw_id IS NULL' reports zero - debris here too, exactly like the live-archive measurement that refuted - it.""" - conn = _seed(archive) - try: - raw_id_null_predicate = int( - conn.execute( - "SELECT COUNT(*) FROM sessions s " - "WHERE NOT EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.session_id) " - "AND (s.raw_id IS NULL OR s.raw_id = '')" - ).fetchone()[0] - ) - assert raw_id_null_predicate == 1 # only "no-provenance" -- the wrong row - assert count_empty_sessions_sync(conn) == 2 # "phantom" + "conversation_relationships" -- the right rows - finally: - conn.close() diff --git a/tests/unit/storage/test_incremental_rebuild_equivalence.py b/tests/unit/storage/test_incremental_rebuild_equivalence.py deleted file mode 100644 index f0c513bb11..0000000000 --- a/tests/unit/storage/test_incremental_rebuild_equivalence.py +++ /dev/null @@ -1,1169 +0,0 @@ -"""One source-grounded survivor for incremental/restart/rebuild equivalence. - -Production dependencies exercised here: - -* ``ArchiveStore.write_raw_payload`` commits durable source evidence. -* ``backfill_historical_revision_evidence`` performs targeted cohort expansion, - typed revision selection, and parsed/index replacement. -* ``repair_session_insights`` materializes the public insight surfaces. -* ``rebuild_index_from_source`` replays retained evidence into an owned inactive - ``IndexGenerationStore`` generation before atomic promotion. - -The test deliberately plants a same-row-count stale FTS row and a stale profile -stamp. Removing full-session replacement from revision replay leaves the stale -search token behind; omitting the insight stage from rebuild leaves profile and -materialization rows absent. Either representative mutation must fail this -survivor. -""" - -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from dataclasses import dataclass -from hashlib import sha256 -from pathlib import Path -from typing import Any - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import Provider -from polylogue.maintenance.replay import rebuild_index_from_source -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.index_generation import ( - IndexGenerationStore, - rebuild_source_evidence_snapshot, - source_revision_snapshot, -) -from polylogue.storage.raw_authority import RAW_AUTHORITY_PARSER_FINGERPRINT -from polylogue.storage.repair import repair_session_insights -from polylogue.storage.runtime import SESSION_INSIGHT_MATERIALIZER_VERSION -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION -from tests.infra.identity import archive_block_id, archive_message_id - -CHAT_SESSION = "chatgpt-export:branch-canary" -PARENT_SESSION = "codex-session:lineage-parent" -CHILD_SESSION = "codex-session:lineage-child" -CHAT_USER = archive_message_id(CHAT_SESSION, "u1", position=0) -CHAT_OLD = archive_message_id(CHAT_SESSION, "a-old", position=1) -CHAT_NEW = archive_message_id(CHAT_SESSION, "a-new", position=1) -CHAT_NEW_BLOCK = archive_block_id(CHAT_NEW, position=0) -PARENT_FIRST = archive_message_id(PARENT_SESSION, "lineage-parent-m0", position=0) -PARENT_SECOND = archive_message_id(PARENT_SESSION, "lineage-parent-m1", position=1) -CHILD_FIRST = archive_message_id(CHILD_SESSION, "lineage-child-m0", position=0) -CANONICAL_TOKEN = "quartzneedle" -STALE_TOKEN = "staleonlytoken" -OVERLAY_TAG = "operator-canary" -PARSER_RECIPE = RAW_AUTHORITY_PARSER_FINGERPRINT - -# Volatile attempt timestamps are not canonical derivation output. The exact -# semantic stamps beside them remain compared. -_VOLATILE_COLUMNS: dict[str, frozenset[str]] = { - "session_links": frozenset({"observed_at_ms", "resolved_at_ms"}), - "raw_revision_heads": frozenset({"decided_at_ms"}), - "session_profiles": frozenset({"materialized_at"}), - "session_latency_profiles": frozenset({"materialized_at"}), - "session_tag_rollups": frozenset({"materialized_at"}), - "insight_materialization": frozenset({"materialized_at_ms"}), - "threads": frozenset({"materialized_at"}), -} - -# These are the independently selected durable/public facts for this canary. -# FTS is compared through the public search method, not through private FTS5 -# segment tables. -_CANONICAL_TABLES = ( - "sessions", - "messages", - "blocks", - "attachments", - "attachment_refs", - "attachment_native_ids", - "session_links", - "raw_revision_heads", - "session_profiles", - "session_latency_profiles", - "session_work_events", - "session_phases", - "session_model_usage", - "session_provider_usage_events", - "session_tag_rollups", - "insight_materialization", - "threads", - "thread_sessions", -) - - -@dataclass(frozen=True, slots=True) -class RawIds: - chat: str - parent_v1: str - parent_v2: str - child: str - - def all(self) -> tuple[str, ...]: - return (self.chat, self.parent_v1, self.parent_v2, self.child) - - -@dataclass(frozen=True, slots=True) -class CanonicalFacts: - tables: tuple[tuple[str, tuple[str, ...], tuple[tuple[Any, ...], ...]], ...] - current_revision_applications: tuple[tuple[Any, ...], ...] - search_hits: tuple[tuple[str, tuple[str, ...]], ...] - - def digest(self) -> str: - payload = json.dumps(_normalize(self), sort_keys=True, separators=(",", ":")) - return sha256(payload.encode()).hexdigest() - - -@dataclass(frozen=True, slots=True) -class DerivationKeyWitness: - """The non-attempt identity required by architecture/05-derived-freshness.""" - - subject_source_bindings: tuple[tuple[Any, ...], ...] - source_snapshot: str - source_evidence: tuple[tuple[Any, ...], ...] - parser_census: tuple[tuple[Any, ...], ...] - recipe_identity: tuple[Any, ...] - output_contract: tuple[tuple[str, tuple[tuple[Any, ...], ...]], ...] - - -@dataclass(frozen=True, slots=True) -class AttemptReceipt: - route: str - index_path: str - index_inode: int - generation_id: str | None - owner_id: str | None - generation_state: str - revision_application_ids: tuple[str, ...] - - -def _chatgpt_branch_payload() -> bytes: - def node( - native_id: str, - role: str, - text: str, - *, - parent: str | None = None, - children: tuple[str, ...] = (), - metadata: dict[str, object] | None = None, - created_at: int, - ) -> dict[str, object]: - message: dict[str, object] = { - "id": native_id, - "author": {"role": role}, - "content": {"content_type": "text", "parts": [text]}, - "create_time": created_at, - } - if metadata is not None: - message["metadata"] = metadata - return { - "id": native_id, - "parent": parent, - "children": list(children), - "message": message, - } - - rows = ( - node("root", "system", "", children=("u1",), created_at=0), - node( - "u1", - "user", - "inspect attachment", - parent="root", - children=("a-old", "a-new"), - metadata={"attachments": [{"id": "att-canary", "name": "canary.dat"}]}, - created_at=1, - ), - node( - "a-old", - "assistant", - "old branch remains visible", - parent="u1", - created_at=2, - ), - node( - "a-new", - "assistant", - f"{CANONICAL_TOKEN} canonical answer", - parent="u1", - created_at=3, - ), - ) - payload = { - "id": "branch-canary", - "conversation_id": "branch-canary", - "title": "Branch canary", - "create_time": 1_700_000_000, - "update_time": 1_700_000_003, - "current_node": "a-new", - "mapping": {str(row["id"]): row for row in rows}, - } - return json.dumps([payload], sort_keys=True).encode() - - -def _codex_session( - native_id: str, - messages: tuple[tuple[str, str], ...], - *, - parent_native_id: str | None = None, -) -> bytes: - # Real Codex continuation/resume rollouts (sampled from ~/.codex/sessions, - # 490/494 multi-session_meta files) replay the parent's original - # session_meta as the file's second distinct session_meta, and that - # replayed header shares the same cwd (and usually the same git - # repository_url) with the child, because a resume continues in the same - # working tree. `_has_continuation_evidence` - # (polylogue/sources/parsers/codex.py, #3484) requires that structural - # match -- a bare second session_meta id is no longer sufficient -- so - # this fixture must carry it for the legacy (no forked_from_id) - # CONTINUATION fallback to classify. - shared_cwd = "/realm/project/lineage-fixture" - rows: list[dict[str, object]] = [ - { - "type": "session_meta", - "payload": {"id": native_id, "timestamp": "2026-07-16T10:00:00Z", "cwd": shared_cwd}, - } - ] - if parent_native_id is not None: - rows.append( - { - "type": "session_meta", - "payload": { - "id": parent_native_id, - "timestamp": "2026-07-16T09:00:00Z", - "cwd": shared_cwd, - }, - } - ) - for position, (role, text) in enumerate(messages): - rows.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{native_id}-m{position}", - "role": role, - "content": [ - { - "type": "input_text" if role == "user" else "output_text", - "text": text, - } - ], - }, - } - ) - return b"".join(json.dumps(row, sort_keys=True).encode() + b"\n" for row in rows) - - -def _config(root: Path, *, index_path: Path | None = None) -> Config: - return Config( - archive_root=root, - render_root=root / "render", - sources=[], - db_path=index_path or root / "index.db", - ) - - -def _connect(path: Path, *, read_only: bool = True) -> sqlite3.Connection: - conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) if read_only else sqlite3.connect(path) - conn.row_factory = sqlite3.Row - return conn - - -def _normalize(value: Any) -> Any: - if isinstance(value, bytes): - return value.hex() - if isinstance(value, CanonicalFacts): - return { - "tables": _normalize(value.tables), - "current_revision_applications": _normalize(value.current_revision_applications), - "search_hits": _normalize(value.search_hits), - } - if isinstance(value, tuple): - return [_normalize(item) for item in value] - if isinstance(value, list): - return [_normalize(item) for item in value] - if isinstance(value, dict): - return {str(key): _normalize(item) for key, item in value.items()} - return value - - -def _schema_columns(conn: sqlite3.Connection, table: str) -> tuple[tuple[Any, ...], ...]: - return tuple( - (row["name"], row["type"], row["notnull"], row["dflt_value"], row["pk"], row["hidden"]) - for row in conn.execute(f'PRAGMA table_xinfo("{table}")') - ) - - -def _table_rows( - conn: sqlite3.Connection, - table: str, -) -> tuple[tuple[str, ...], tuple[tuple[Any, ...], ...]]: - excluded = _VOLATILE_COLUMNS.get(table, frozenset()) - columns = tuple( - row["name"] for row in conn.execute(f'PRAGMA table_xinfo("{table}")') if row["name"] not in excluded - ) - quoted = ", ".join(f'"{column}"' for column in columns) - rows = tuple( - sorted( - (tuple(_normalize(value) for value in row) for row in conn.execute(f'SELECT {quoted} FROM "{table}"')), - key=repr, - ) - ) - return columns, rows - - -def _current_revision_applications(conn: sqlite3.Connection) -> tuple[tuple[Any, ...], ...]: - rows = conn.execute( - """ - WITH ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY raw_id, session_id - ORDER BY acquisition_generation DESC, decided_at_ms DESC, decision_id DESC - ) AS attempt_rank - FROM raw_revision_applications - ) - SELECT - decision_id, - raw_id, - session_id, - logical_source_key, - source_revision, - acquisition_generation, - decision, - accepted_raw_id, - accepted_source_revision, - accepted_content_hash, - baseline_raw_id, - predecessor_raw_id, - append_end_offset, - detail - FROM ranked - WHERE attempt_rank = 1 - ORDER BY raw_id, session_id - """ - ) - return tuple(tuple(_normalize(value) for value in row) for row in rows) - - -def _collect_canonical_facts(route_root: Path, index_path: Path) -> CanonicalFacts: - with _connect(index_path) as conn: - tables = tuple((table, *_table_rows(conn, table)) for table in _CANONICAL_TABLES) - applications = _current_revision_applications(conn) - with ArchiveStore.open_existing(route_root, read_only=True) as archive: - searches = tuple( - (query, tuple(archive.search_blocks(query))) - for query in (CANONICAL_TOKEN, STALE_TOKEN, "definitelyabsentcanary") - ) - return CanonicalFacts( - tables=tables, - current_revision_applications=applications, - search_hits=searches, - ) - - -def _source_evidence(root: Path) -> tuple[tuple[Any, ...], ...]: - with _connect(root / "source.db") as conn: - rows = conn.execute( - """ - SELECT - raw_id, - origin, - capture_mode, - source_path, - source_index, - blob_hash, - blob_size, - acquired_at_ms, - logical_source_key, - revision_kind, - source_revision, - predecessor_source_revision, - predecessor_raw_id, - baseline_raw_id, - append_start_offset, - append_end_offset, - acquisition_generation, - revision_authority - FROM raw_sessions - ORDER BY raw_id - """ - ) - return tuple(tuple(_normalize(value) for value in row) for row in rows) - - -def _parser_census(root: Path) -> tuple[tuple[Any, ...], ...]: - with _connect(root / "source.db") as conn: - rows = conn.execute( - """ - SELECT raw_id, parser_fingerprint, status, logical_keys_json, detail - FROM raw_authority_parser_census - ORDER BY raw_id - """ - ) - return tuple(tuple(_normalize(value) for value in row) for row in rows) - - -def _fts_recipe(conn: sqlite3.Connection) -> str: - row = conn.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages_fts'").fetchone() - assert row is not None - return " ".join(str(row["sql"]).split()) - - -def _derivation_key(root: Path, index_path: Path) -> DerivationKeyWitness: - with _connect(index_path) as conn: - bindings = tuple( - tuple(_normalize(value) for value in row) - for row in conn.execute( - """ - SELECT - sessions.session_id, - sessions.raw_id, - sessions.content_hash, - raw_revision_heads.logical_source_key, - raw_revision_heads.accepted_raw_id, - raw_revision_heads.accepted_source_revision, - raw_revision_heads.accepted_content_hash, - raw_revision_heads.accepted_frontier_kind, - raw_revision_heads.accepted_frontier, - raw_revision_heads.acquisition_generation - FROM sessions - JOIN raw_revision_heads USING (session_id) - ORDER BY sessions.session_id - """ - ) - ) - output_contract = tuple( - (table, _schema_columns(conn, table)) - for table in (*_CANONICAL_TABLES, "raw_revision_applications", "messages_fts") - ) - user_version = int(conn.execute("PRAGMA user_version").fetchone()[0]) - recipe_identity = ( - ("index_schema", INDEX_SCHEMA_VERSION, user_version), - ("fts_contract", _fts_recipe(conn)), - ("insight_materializer", SESSION_INSIGHT_MATERIALIZER_VERSION), - ("parser_recipe", tuple(sorted({row[1] for row in _parser_census(root)}))), - ) - return DerivationKeyWitness( - subject_source_bindings=bindings, - # Immutable evidence, not the full mutable row hash: this witness is - # compared ACROSS roots, and parse/validation columns are rebuild - # outputs that legitimately differ between two routes. - source_snapshot=rebuild_source_evidence_snapshot(root), - source_evidence=_source_evidence(root), - parser_census=_parser_census(root), - recipe_identity=recipe_identity, - output_contract=output_contract, - ) - - -def _overlay_assertions(root: Path) -> tuple[tuple[Any, ...], ...]: - with _connect(root / "user.db") as conn: - rows = conn.execute( - """ - SELECT - assertion_id, - scope_ref, - target_ref, - key, - kind, - value_json, - body_text, - author_ref, - author_kind, - evidence_refs_json, - status, - visibility, - confidence, - staleness_json, - context_policy_json, - supersedes_json - FROM assertions - ORDER BY assertion_id - """ - ) - return tuple(tuple(_normalize(value) for value in row) for row in rows) - - -def _session_content_hash(index_path: Path, session_id: str) -> str: - with _connect(index_path) as conn: - row = conn.execute( - "SELECT content_hash FROM sessions WHERE session_id = ?", - (session_id,), - ).fetchone() - assert row is not None - return bytes(row["content_hash"]).hex() - - -def _assert_raw_payload_identity(root: Path, raw_id: str, payload: bytes) -> None: - with _connect(root / "source.db") as conn: - row = conn.execute( - "SELECT blob_hash, blob_size FROM raw_sessions WHERE raw_id = ?", - (raw_id,), - ).fetchone() - assert row is not None - assert bytes(row["blob_hash"]) == sha256(payload).digest() - assert int(row["blob_size"]) == len(payload) - - -def _plant_stale_dependents(index_path: Path) -> None: - with _connect(index_path, read_only=False) as conn: - fts_row = conn.execute( - """ - SELECT rowid, block_id, message_id, session_id, block_type - FROM blocks - WHERE block_id = ? - """, - (CHAT_NEW_BLOCK,), - ).fetchone() - assert fts_row is not None - fts_count = int(conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) - conn.execute("DELETE FROM messages_fts WHERE rowid = ?", (fts_row["rowid"],)) - conn.execute( - """ - INSERT INTO messages_fts(rowid, block_id, message_id, session_id, block_type, text) - VALUES (?, ?, ?, ?, ?, ?) - """, - (*tuple(fts_row), STALE_TOKEN), - ) - conn.execute( - "UPDATE session_profiles SET message_count = 999 WHERE session_id = ?", - (CHAT_SESSION,), - ) - conn.execute( - """ - UPDATE insight_materialization - SET materializer_version = 0 - WHERE insight_type = 'session_profile' AND session_id = ? - """, - (CHAT_SESSION,), - ) - conn.commit() - assert int(conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) == fts_count - - -def _mark_raws_for_restart(root: Path, raw_ids: tuple[str, ...]) -> None: - placeholders = ",".join("?" for _ in raw_ids) - with _connect(root / "source.db", read_only=False) as conn: - conn.execute( - f"UPDATE raw_sessions SET parsed_at_ms = NULL WHERE raw_id IN ({placeholders})", - raw_ids, - ) - conn.commit() - - -def _application_receipts(index_path: Path) -> tuple[tuple[Any, ...], ...]: - with _connect(index_path) as conn: - rows = conn.execute( - """ - SELECT decision_id, raw_id, acquisition_generation, decision, accepted_raw_id - FROM raw_revision_applications - ORDER BY raw_id, acquisition_generation, decision_id - """ - ) - return tuple(tuple(_normalize(value) for value in row) for row in rows) - - -def _attempt_receipt( - *, - route: str, - index_path: Path, - generation_id: str | None, - owner_id: str | None, - generation_state: str, -) -> AttemptReceipt: - return AttemptReceipt( - route=route, - index_path=str(index_path), - index_inode=index_path.stat().st_ino, - generation_id=generation_id, - owner_id=owner_id, - generation_state=generation_state, - revision_application_ids=tuple(row[0] for row in _application_receipts(index_path)), - ) - - -def _assert_planted_contract(root: Path, index_path: Path, raw_ids: RawIds) -> None: - """Assert facts derived from planted input, never from another route.""" - with _connect(index_path) as conn: - session_rows = { - row["session_id"]: ( - row["parent_session_id"], - row["root_session_id"], - row["raw_id"], - row["branch_type"], - row["active_leaf_message_id"], - row["message_count"], - ) - for row in conn.execute( - """ - SELECT - session_id, - parent_session_id, - root_session_id, - raw_id, - branch_type, - active_leaf_message_id, - message_count - FROM sessions - ORDER BY session_id - """ - ) - } - assert session_rows == { - CHAT_SESSION: (None, CHAT_SESSION, raw_ids.chat, None, CHAT_NEW, 3), - PARENT_SESSION: (None, PARENT_SESSION, raw_ids.parent_v2, None, PARENT_SECOND, 2), - CHILD_SESSION: ( - PARENT_SESSION, - PARENT_SESSION, - raw_ids.child, - "continuation", - CHILD_FIRST, - 1, - ), - } - - messages = { - row["message_id"]: ( - row["parent_message_id"], - row["role"], - row["variant_index"], - row["is_active_path"], - row["is_active_leaf"], - ) - for row in conn.execute( - """ - SELECT - message_id, - parent_message_id, - role, - variant_index, - is_active_path, - is_active_leaf - FROM messages - ORDER BY message_id - """ - ) - } - assert messages == { - CHAT_USER: (None, "user", 0, 1, 0), - CHAT_OLD: (CHAT_USER, "assistant", 0, 0, 0), - CHAT_NEW: (CHAT_USER, "assistant", 1, 1, 1), - PARENT_FIRST: (None, "user", 0, 1, 0), - # bd polylogue-ksgg: codex carries no real parent-link evidence of - # its own, so fill_linear_parent_chain now correctly chains this - # append-added message to the prior active-path message. - PARENT_SECOND: (PARENT_FIRST, "assistant", 0, 1, 1), - CHILD_FIRST: (None, "user", 0, 1, 1), - } - - blocks = { - row["block_id"]: (row["block_type"], row["text"]) - for row in conn.execute("SELECT block_id, block_type, text FROM blocks ORDER BY block_id") - } - assert blocks[CHAT_NEW_BLOCK] == ("text", f"{CANONICAL_TOKEN} canonical answer") - assert f"{CHAT_OLD}:0" in blocks - assert f"{CHAT_USER}:0" in blocks - - attachment = conn.execute( - """ - SELECT - attachments.display_name, - attachments.media_type, - attachments.byte_count, - attachments.blob_hash, - attachments.acquisition_status, - attachments.ref_count, - attachment_refs.session_id, - attachment_refs.message_id, - attachment_refs.position, - attachment_refs.upload_origin, - attachment_refs.source_url, - attachment_refs.caption, - attachment_native_ids.id_kind, - attachment_native_ids.native_id - FROM attachments - JOIN attachment_refs USING (attachment_id) - JOIN attachment_native_ids ON attachment_native_ids.ref_id = attachment_refs.ref_id - """ - ).fetchone() - assert attachment is not None - assert int(attachment["position"]) >= 0 - assert tuple( - attachment[key] - for key in ( - "display_name", - "media_type", - "byte_count", - "blob_hash", - "acquisition_status", - "ref_count", - "session_id", - "message_id", - "upload_origin", - "source_url", - "caption", - "id_kind", - "native_id", - ) - ) == ( - "canary.dat", - None, - 0, - None, - "unfetched", - 1, - CHAT_SESSION, - CHAT_USER, - "oauth", - None, - None, - "attachment", - "att-canary", - ) - - link = conn.execute( - """ - SELECT - src_session_id, - dst_origin, - dst_native_id, - link_type, - resolved_dst_session_id, - branch_point_message_id, - inheritance, - status, - method, - confidence, - evidence_json - FROM session_links - """ - ).fetchone() - assert link is not None - assert tuple(link[:10]) == ( - CHILD_SESSION, - "codex-session", - "lineage-parent", - "continuation", - PARENT_SESSION, - None, - "spawned-fresh", - None, - "parser-parent", - 1.0, - ) - assert json.loads(str(link["evidence_json"])) == {"parent_session_provider_id": "lineage-parent"} - - heads = { - row["session_id"]: ( - row["accepted_raw_id"], - bytes(row["accepted_content_hash"]).hex(), - row["acquisition_generation"], - ) - for row in conn.execute( - """ - SELECT session_id, accepted_raw_id, accepted_content_hash, acquisition_generation - FROM raw_revision_heads - ORDER BY session_id - """ - ) - } - session_hashes = { - row["session_id"]: bytes(row["content_hash"]).hex() - for row in conn.execute("SELECT session_id, content_hash FROM sessions") - } - assert heads == { - CHAT_SESSION: (raw_ids.chat, session_hashes[CHAT_SESSION], 0), - PARENT_SESSION: (raw_ids.parent_v2, session_hashes[PARENT_SESSION], 1), - CHILD_SESSION: (raw_ids.child, session_hashes[CHILD_SESSION], 0), - } - - parent_decisions = { - (row["raw_id"], row["acquisition_generation"], row["decision"]) - for row in conn.execute( - """ - WITH ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY raw_id, session_id - ORDER BY acquisition_generation DESC, decided_at_ms DESC, decision_id DESC - ) AS attempt_rank - FROM raw_revision_applications - ) - SELECT raw_id, acquisition_generation, decision - FROM ranked - WHERE attempt_rank = 1 AND session_id = ? - """, - (PARENT_SESSION,), - ) - } - assert parent_decisions == { - (raw_ids.parent_v1, 1, "superseded"), - (raw_ids.parent_v2, 1, "selected_baseline"), - } - - profiles = { - row["session_id"]: ( - row["message_count"], - row["attachment_count"], - row["tags_json"], - row["materializer_version"], - ) - for row in conn.execute( - """ - SELECT session_id, message_count, attachment_count, tags_json, materializer_version - FROM session_profiles - ORDER BY session_id - """ - ) - } - assert profiles == { - CHAT_SESSION: (3, 1, None, SESSION_INSIGHT_MATERIALIZER_VERSION), - PARENT_SESSION: (2, 0, None, SESSION_INSIGHT_MATERIALIZER_VERSION), - CHILD_SESSION: (1, 0, None, SESSION_INSIGHT_MATERIALIZER_VERSION), - } - - materializations = tuple( - conn.execute( - """ - SELECT insight_type, session_id, materializer_version - FROM insight_materialization - ORDER BY session_id, insight_type - """ - ) - ) - assert len(materializations) == 27 - assert {int(row[2]) for row in materializations} == {SESSION_INSIGHT_MATERIALIZER_VERSION} - assert {str(row[0]) for row in materializations} == { - "context_snapshots", - "latency", - "observed_events", - "phases", - "provider_usage", - "runs", - "session_profile", - "thread", - "work_events", - } - - thread_rows = { - row["thread_id"]: ( - tuple(json.loads(str(row["session_ids_json"]))), - row["session_count"], - row["depth"], - row["branch_count"], - row["total_messages"], - row["materializer_version"], - ) - for row in conn.execute( - """ - SELECT - thread_id, - session_ids_json, - session_count, - depth, - branch_count, - total_messages, - materializer_version - FROM threads - ORDER BY thread_id - """ - ) - } - assert thread_rows == { - CHAT_SESSION: ( - (CHAT_SESSION,), - 1, - 0, - 1, - 3, - SESSION_INSIGHT_MATERIALIZER_VERSION, - ), - PARENT_SESSION: ( - (PARENT_SESSION, CHILD_SESSION), - 2, - 1, - 1, - 3, - SESSION_INSIGHT_MATERIALIZER_VERSION, - ), - } - - with ArchiveStore.open_existing(root, read_only=True) as archive: - assert archive.search_blocks(CANONICAL_TOKEN) == [CHAT_NEW_BLOCK] - assert archive.search_blocks(STALE_TOKEN) == [] - assert archive.search_blocks("definitelyabsentcanary") == [] - - -def test_incremental_restart_and_fresh_generation_rebuild_are_equivalent( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """One realized workload converges across incremental, restart, and rebuild routes.""" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - monkeypatch.setenv("POLYLOGUE_SCHEMA_VALIDATION", "off") - initialize_active_archive_root(tmp_path) - - chat_payload = _chatgpt_branch_payload() - parent_v1_payload = _codex_session("lineage-parent", (("user", "parent first"),)) - # This is deliberately a strict byte-prefix extension of v1. - parent_v2_payload = ( - parent_v1_payload - + json.dumps( - { - "type": "response_item", - "payload": { - "type": "message", - "id": "lineage-parent-m1", - "role": "assistant", - "content": [{"type": "output_text", "text": "parent second"}], - }, - }, - sort_keys=True, - ).encode() - + b"\n" - ) - child_payload = _codex_session( - "lineage-child", - (("user", "child continuation"),), - parent_native_id="lineage-parent", - ) - assert parent_v2_payload.startswith(parent_v1_payload) - - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - chat_raw = archive.write_raw_payload( - provider=Provider.CHATGPT, - payload=chat_payload, - source_path="conversations.json", - acquired_at_ms=1, - ) - parent_v1_raw = archive.write_raw_payload( - provider=Provider.CODEX, - payload=parent_v1_payload, - source_path="parent.jsonl", - acquired_at_ms=2, - ) - _assert_raw_payload_identity(tmp_path, chat_raw, chat_payload) - _assert_raw_payload_identity(tmp_path, parent_v1_raw, parent_v1_payload) - - first_increment = backfill_historical_revision_evidence( - tmp_path, - selected_raw_ids=[chat_raw, parent_v1_raw], - ) - assert ( - first_increment.scanned, - first_increment.classified_full, - first_increment.replayed_logical_sources, - first_increment.quarantined, - ) == (2, 2, 2, 0) - - source_before_overlay = source_revision_snapshot(tmp_path) - chat_hash_before_overlay = _session_content_hash(tmp_path / "index.db", CHAT_SESSION) - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - assert archive.add_user_tags((CHAT_SESSION,), (OVERLAY_TAG,)) == 1 - overlay_receipt = _overlay_assertions(tmp_path) - assert len(overlay_receipt) == 1 - assert overlay_receipt[0][2:5] == ( - f"session:{CHAT_SESSION}", - OVERLAY_TAG, - "tag", - ) - assert source_revision_snapshot(tmp_path) == source_before_overlay - assert _session_content_hash(tmp_path / "index.db", CHAT_SESSION) == chat_hash_before_overlay - - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - parent_v2_raw = archive.write_raw_payload( - provider=Provider.CODEX, - payload=parent_v2_payload, - source_path="parent.jsonl", - acquired_at_ms=3, - ) - child_raw = archive.write_raw_payload( - provider=Provider.CODEX, - payload=child_payload, - source_path="child.jsonl", - acquired_at_ms=4, - ) - raw_ids = RawIds(chat_raw, parent_v1_raw, parent_v2_raw, child_raw) - _assert_raw_payload_identity(tmp_path, raw_ids.parent_v2, parent_v2_payload) - _assert_raw_payload_identity(tmp_path, raw_ids.child, child_payload) - - targeted_update = backfill_historical_revision_evidence( - tmp_path, - selected_raw_ids=[parent_v2_raw, child_raw], - ) - assert ( - targeted_update.scanned, - targeted_update.classified_full, - targeted_update.replayed_logical_sources, - targeted_update.quarantined, - ) == (3, 3, 2, 0) - first_insights = repair_session_insights(_config(tmp_path), dry_run=False) - assert first_insights.success is True - assert first_insights.detail == "Session insights ready" - targeted_key = _derivation_key(tmp_path, tmp_path / "index.db") - targeted_facts = _collect_canonical_facts(tmp_path, tmp_path / "index.db") - _assert_planted_contract(tmp_path, tmp_path / "index.db", raw_ids) - - # Anti-vacuity: same row count, wrong FTS content, and an explicitly stale - # materializer recipe. A row-count or boolean-stale test would miss this. - _plant_stale_dependents(tmp_path / "index.db") - with ArchiveStore.open_existing(tmp_path, read_only=True) as archive: - assert archive.search_blocks(STALE_TOKEN) == [CHAT_NEW_BLOCK] - assert archive.search_blocks(CANONICAL_TOKEN) == [] - with _connect(tmp_path / "index.db") as conn: - assert ( - conn.execute( - "SELECT message_count FROM session_profiles WHERE session_id = ?", - (CHAT_SESSION,), - ).fetchone()[0] - == 999 - ) - - # Restart/reprocess the selected terminal raws. Cohort expansion must still - # reconsider the parent v1 predecessor and preserve the old attempt receipt. - _mark_raws_for_restart(tmp_path, raw_ids.all()) - restarted = backfill_historical_revision_evidence( - tmp_path, - selected_raw_ids=[chat_raw, parent_v2_raw, child_raw], - ) - assert restarted.scanned == 4 - assert restarted.classified_full == 4 - assert restarted.replayed_logical_sources == 3 - assert restarted.quarantined == 0 - restarted_insights = repair_session_insights(_config(tmp_path), dry_run=False) - assert restarted_insights.success is True - assert restarted_insights.detail == "Session insights ready" - - final_source_snapshot = source_revision_snapshot(tmp_path) - final_source_evidence = rebuild_source_evidence_snapshot(tmp_path) - incremental_path = tmp_path / "index.db" - incremental_key = _derivation_key(tmp_path, incremental_path) - incremental_facts = _collect_canonical_facts(tmp_path, incremental_path) - incremental_attempts = _application_receipts(incremental_path) - assert incremental_key == targeted_key - assert incremental_facts == targeted_facts - incremental_receipt = _attempt_receipt( - route="incremental+targeted+restart", - index_path=incremental_path, - generation_id=None, - owner_id=None, - generation_state="legacy-active", - ) - _assert_planted_contract(tmp_path, incremental_path, raw_ids) - assert _overlay_assertions(tmp_path) == overlay_receipt - assert incremental_key.source_snapshot == final_source_evidence - assert {row[1] for row in incremental_key.parser_census} == {PARSER_RECIPE} - assert incremental_key.recipe_identity[0] == ( - "index_schema", - INDEX_SCHEMA_VERSION, - INDEX_SCHEMA_VERSION, - ) - assert "unicode61 remove_diacritics 2" in str(incremental_key.recipe_identity[1]) - - generation_store = IndexGenerationStore.for_archive_root(tmp_path) - generation = generation_store.create( - owner_id="testdiet-03-rebuild", - source_snapshot=final_source_snapshot, - ) - generation_path = Path(generation.index_path) - generation_root = generation_path.parent - rebuild_result = asyncio.run( - rebuild_index_from_source( - _config(generation_root, index_path=generation_path), - raw_ids=list(raw_ids.all()), - raw_batch_size=500, - ingest_workers=1, - materialize=True, - progress_callback=None, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - ) - ) - # Timing and memory-envelope telemetry vary with the host and run. They are - # verified by their own bounded-envelope contract; content equivalence is - # asserted over deterministic replay counts only. - _TELEMETRY_KEYS = {"parse_s", "apply_s", "stage_timings_s", "whale_envelope"} - assert {k: v for k, v in rebuild_result.items() if k not in _TELEMETRY_KEYS} == { - "scanned_raw_count": 4, - "classified_full_count": 4, - "replayed_logical_source_count": 3, - "quarantined_raw_count": 0, - "adoption_deferred_raw_count": 0, - "authority_selection_expanded": True, - "scheduled_raw_count": 4, - "raw_batch_size": 500, - "ingest_workers": 1, - } - whale_envelope = rebuild_result["whale_envelope"] - assert isinstance(whale_envelope, dict) - assert whale_envelope["decoded_cache_tree_budget_bytes"] > 0 - assert whale_envelope["whale_cache_tree_budget_bytes"] >= whale_envelope["decoded_cache_tree_budget_bytes"] - assert whale_envelope["largest_tree_bytes_seen"] > 0 - rebuilt_insights = repair_session_insights( - _config(generation_root, index_path=generation_path), - dry_run=False, - archive_root_override=generation_root, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - ) - assert rebuilt_insights.success is True - assert rebuilt_insights.detail == "Session insights ready" - assert generation_store.load(generation.generation_id).state == "inactive" - assert source_revision_snapshot(tmp_path) == final_source_snapshot - - rebuild_key = _derivation_key(generation_root, generation_path) - rebuild_facts = _collect_canonical_facts(generation_root, generation_path) - rebuild_attempts = _application_receipts(generation_path) - rebuild_receipt = _attempt_receipt( - route="fresh-owned-generation", - index_path=generation_path, - generation_id=generation.generation_id, - owner_id=generation.owner_id, - generation_state="inactive", - ) - _assert_planted_contract(generation_root, generation_path, raw_ids) - - # Exact derivation-key identity and canonical output, with generation and - # attempt identity deliberately outside the equivalence key. - assert rebuild_key == incremental_key - assert rebuild_facts == incremental_facts - assert rebuild_facts.digest() == incremental_facts.digest() - assert rebuild_receipt != incremental_receipt - assert rebuild_receipt.index_path != incremental_receipt.index_path - assert rebuild_receipt.index_inode != incremental_receipt.index_inode - assert rebuild_receipt.generation_id is not None - assert rebuild_receipt.owner_id == "testdiet-03-rebuild" - - # Incremental history retains its earlier selected-v1 receipt. A fresh - # rebuild has only final cohort receipts, while their current decisions are - # equal through ``CanonicalFacts.current_revision_applications``. - prior_v1_receipts = [ - row - for row in incremental_attempts - if row[1] == raw_ids.parent_v1 and row[2] == 0 and row[3] == "selected_baseline" - ] - assert len(prior_v1_receipts) == 1 - assert prior_v1_receipts[0] not in rebuild_attempts - assert len(incremental_attempts) == len(rebuild_attempts) + 1 - - # The overlay is deliberately outside raw/session hashes and is rejoined by - # the generation's user.db symlink, not copied into the content identity. - assert _overlay_assertions(generation_root) == overlay_receipt - assert _session_content_hash(generation_path, CHAT_SESSION) == chat_hash_before_overlay - assert source_revision_snapshot(generation_root) == final_source_snapshot - - promoted = generation_store.promote(generation) - assert promoted.state == "active" - assert generation_store.load(generation.generation_id).state == "active" - assert (tmp_path / "index.db").resolve() == generation_path.resolve() - assert _collect_canonical_facts(tmp_path, tmp_path / "index.db") == rebuild_facts - assert _derivation_key(tmp_path, tmp_path / "index.db") == rebuild_key - assert _overlay_assertions(tmp_path) == overlay_receipt - assert source_revision_snapshot(tmp_path) == final_source_snapshot diff --git a/tests/unit/storage/test_index.py b/tests/unit/storage/test_index.py deleted file mode 100644 index 0086bd4e0c..0000000000 --- a/tests/unit/storage/test_index.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -import sqlite3 - -import pytest - - -def test_rebuild_index_rebuilds_message_fts_only(monkeypatch: pytest.MonkeyPatch) -> None: - from polylogue.storage import index as index_mod - - conn = sqlite3.connect(":memory:") - fts_calls: list[sqlite3.Connection] = [] - - monkeypatch.setattr(index_mod, "rebuild_fts_index_sync", lambda db_conn: fts_calls.append(db_conn)) - monkeypatch.setattr(index_mod, "invalidate_search_cache", lambda: None) - - index_mod.rebuild_index(conn) - - assert fts_calls == [conn] - - -def test_update_index_repairs_message_fts_targets(monkeypatch: pytest.MonkeyPatch) -> None: - from polylogue.storage import index as index_mod - - conn = sqlite3.connect(":memory:") - repaired_fts_targets: list[list[str]] = [] - - monkeypatch.setattr( - index_mod, - "repair_fts_index_sync", - lambda db_conn, session_ids: repaired_fts_targets.append(list(session_ids)), - ) - monkeypatch.setattr(index_mod, "invalidate_search_cache", lambda: None) - - index_mod.update_index_for_sessions(["conv-a", "conv-b"], conn) - - assert repaired_fts_targets == [["conv-a", "conv-b"]] diff --git a/tests/unit/storage/test_repair.py b/tests/unit/storage/test_raw_convergence.py similarity index 100% rename from tests/unit/storage/test_repair.py rename to tests/unit/storage/test_raw_convergence.py diff --git a/tests/unit/storage/test_reindex_derived_model_differential.py b/tests/unit/storage/test_reindex_derived_model_differential.py deleted file mode 100644 index 39b55aee27..0000000000 --- a/tests/unit/storage/test_reindex_derived_model_differential.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Derived-model differential survivor for full reindex and convergence routes. - -Production dependencies exercised here: - -* ``backfill_historical_revision_evidence`` is the incremental parsed-index - route over retained raw bytes. -* ``repair_session_insights`` is the production convergence repair route. -* ``rebuild_index_from_source`` builds each owned inactive generation from - the same durable source evidence. - -The controls below mutate real temporary SQLite archives after a green route. -They prove this differential rejects omitted repair, a missing per-session -projection, stale FTS text with unchanged row count, and a stale profile with -unchanged row count. -""" - -from __future__ import annotations - -import asyncio -import json -import shutil -import sqlite3 -from pathlib import Path - -import pytest - -from polylogue.config import Config -from polylogue.core.enums import Provider -from polylogue.maintenance.replay import rebuild_index_from_source -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.index_generation import IndexGeneration, IndexGenerationStore, source_revision_snapshot -from polylogue.storage.repair import repair_session_insights -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.reindex_differential import ( - DerivedModelSnapshot, - assert_derived_model_ready, - assert_derived_models_equivalent, - snapshot_derived_model, -) - -_SESSION_ID = "chatgpt-export:derived-differential" -_CANONICAL_TOKEN = "reindexquartz" -_STALE_TOKEN = "stalequartz" -_SEARCH_QUERIES = (_CANONICAL_TOKEN, _STALE_TOKEN, "definitelyabsentderived") - - -def _config(root: Path, index_path: Path) -> Config: - return Config(archive_root=root, render_root=root / "render", sources=[], db_path=index_path) - - -def _chatgpt_payload() -> bytes: - def node( - native_id: str, - role: str, - text: str, - *, - parent: str | None, - children: tuple[str, ...], - created_at: int, - ) -> dict[str, object]: - return { - "id": native_id, - "parent": parent, - "children": list(children), - "message": { - "id": native_id, - "author": {"role": role}, - "content": {"content_type": "text", "parts": [text]}, - "create_time": created_at, - }, - } - - rows = ( - node("root", "system", "", parent=None, children=("user",), created_at=1), - node("user", "user", "inspect the derived-model route", parent="root", children=("assistant",), created_at=2), - node( - "assistant", - "assistant", - f"{_CANONICAL_TOKEN} canonical indexed answer", - parent="user", - children=(), - created_at=3, - ), - ) - return json.dumps( - [ - { - "id": "derived-differential", - "conversation_id": "derived-differential", - "title": "Derived model differential", - "create_time": 1_700_000_000, - "update_time": 1_700_000_003, - "current_node": "assistant", - "mapping": {str(row["id"]): row for row in rows}, - } - ], - sort_keys=True, - ).encode() - - -def _snapshot(root: Path, index_path: Path) -> DerivedModelSnapshot: - return snapshot_derived_model( - root, - index_path, - session_ids=(_SESSION_ID,), - search_queries=_SEARCH_QUERIES, - ) - - -def _repair(root: Path, index_path: Path, generation: IndexGeneration | None = None) -> None: - owned_generation = None if generation is None else (generation.generation_id, generation.owner_id) - result = repair_session_insights( - _config(root, index_path), - dry_run=False, - archive_root_override=root if generation is not None else None, - owned_inactive_generation=owned_generation, - ) - assert result.success is True - assert result.detail == "Session insights ready" - - -def _rebuild_generation( - root: Path, - store: IndexGenerationStore, - raw_id: str, - *, - owner_id: str, -) -> tuple[IndexGeneration, Path]: - generation = store.create(owner_id=owner_id, source_snapshot=source_revision_snapshot(root)) - index_path = Path(generation.index_path) - result = asyncio.run( - rebuild_index_from_source( - _config(index_path.parent, index_path), - raw_ids=[raw_id], - raw_batch_size=100, - ingest_workers=1, - materialize=True, - progress_callback=None, - owned_inactive_generation=(generation.generation_id, generation.owner_id), - ) - ) - assert result["quarantined_raw_count"] == 0 - assert result["replayed_logical_source_count"] == 1 - assert store.load(generation.generation_id).state == "inactive" - return generation, index_path - - -def _assert_control_rejected(expected: DerivedModelSnapshot, root: Path, index_path: Path) -> None: - with pytest.raises(AssertionError): - assert_derived_models_equivalent(expected, _snapshot(root, index_path)) - - -def _replace_fts_text(index_path: Path) -> None: - with sqlite3.connect(index_path) as conn: - row = conn.execute( - """ - SELECT rowid, block_id, message_id, session_id, block_type - FROM blocks - WHERE text LIKE ? - """, - (f"%{_CANONICAL_TOKEN}%",), - ).fetchone() - assert row is not None - count = int(conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) - conn.execute("DELETE FROM messages_fts WHERE rowid = ?", (row[0],)) - conn.execute( - """ - INSERT INTO messages_fts(rowid, block_id, message_id, session_id, block_type, text) - VALUES (?, ?, ?, ?, ?, ?) - """, - (*row, _STALE_TOKEN), - ) - conn.commit() - assert int(conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) == count - - -def test_full_reindex_and_incremental_convergence_have_equal_derived_models( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Two full generations and the incremental convergence route agree.""" - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - monkeypatch.setenv("POLYLOGUE_SCHEMA_VALIDATION", "off") - initialize_active_archive_root(tmp_path) - - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - raw_id = archive.write_raw_payload( - provider=Provider.CHATGPT, - payload=_chatgpt_payload(), - source_path="derived-differential.json", - acquired_at_ms=1, - ) - incremental = backfill_historical_revision_evidence(tmp_path, selected_raw_ids=[raw_id]) - assert incremental.quarantined == 0 - assert incremental.replayed_logical_sources == 1 - incremental_index = tmp_path / "index.db" - _repair(tmp_path, incremental_index) - incremental_snapshot = _snapshot(tmp_path, incremental_index) - assert_derived_model_ready(incremental_snapshot) - - store = IndexGenerationStore.for_archive_root(tmp_path) - first_generation, first_index = _rebuild_generation(tmp_path, store, raw_id, owner_id="test-differential-full-a") - - # Mutation control: omitting the production repair call leaves the derived - # model stale/partial even though raw replay completed successfully. - _assert_control_rejected(incremental_snapshot, first_index.parent, first_index) - _repair(first_index.parent, first_index, first_generation) - first_snapshot = _snapshot(first_index.parent, first_index) - assert_derived_model_ready(first_snapshot) - - second_generation, second_index = _rebuild_generation(tmp_path, store, raw_id, owner_id="test-differential-full-b") - _repair(second_index.parent, second_index, second_generation) - second_snapshot = _snapshot(second_index.parent, second_index) - assert_derived_model_ready(second_snapshot) - - assert_derived_models_equivalent(incremental_snapshot, first_snapshot) - assert_derived_models_equivalent(first_snapshot, second_snapshot) - - reference_index = tmp_path / "reference-index.db" - shutil.copy2(second_index, reference_index) - - # Mutation control: one per-session table can disappear while the rest of - # the materializer output remains present. - with sqlite3.connect(second_index) as conn: - conn.execute("DELETE FROM session_profiles WHERE session_id = ?", (_SESSION_ID,)) - conn.commit() - _assert_control_rejected(second_snapshot, second_index.parent, second_index) - shutil.copy2(reference_index, second_index) - - # Mutation control: stale FTS text retains the exact same number of rows. - _replace_fts_text(second_index) - _assert_control_rejected(second_snapshot, second_index.parent, second_index) - shutil.copy2(reference_index, second_index) - - # Mutation control: a profile can be stale without changing any table's - # row count, so the table and public-read comparisons must both matter. - with sqlite3.connect(second_index) as conn: - count = int(conn.execute("SELECT COUNT(*) FROM session_profiles").fetchone()[0]) - conn.execute("UPDATE session_profiles SET message_count = 999 WHERE session_id = ?", (_SESSION_ID,)) - conn.commit() - assert int(conn.execute("SELECT COUNT(*) FROM session_profiles").fetchone()[0]) == count - _assert_control_rejected(second_snapshot, second_index.parent, second_index) diff --git a/tests/unit/test_session_profile_staleness_predicate.py b/tests/unit/test_session_profile_staleness_predicate.py deleted file mode 100644 index 95652e9af0..0000000000 --- a/tests/unit/test_session_profile_staleness_predicate.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Regression coverage for polylogue-a7xr.2: converger/repair staleness agreement. - -Prior bug: ``daemon/convergence_stages.py`` and ``storage/repair.py`` encoded -*different* staleness predicates for ``session_profiles``/ -``session_latency_profiles`` rows whose owning session has ``sort_key_ms IS -NULL`` (a "timeless" session with no derivable temporal sort key). The -converger compared the profile's cached ``source_updated_at`` against the -session's ``updated_at_ms``; repair instead COALESCEd the missing sort key to -``0.0`` and compared it against the profile's ``source_sort_key`` — which -permanently flagged any NULL-``sort_key_ms`` session with a nonzero cached -``source_sort_key`` as stale, regardless of whether the converger considered -it fresh. That produced either indefinite repair churn (repair repeatedly -"fixing" a row the converger already materialized correctly) or missed -rebuilds, depending on which path ran first. - -Both paths now compose their staleness check from the single -``session_profile_stale_predicate`` builder in -``polylogue/storage/insights/session/runtime.py``. This test proves the fix -two ways: (1) a fixture with ``sort_key_ms IS NULL`` and a nonzero cached -``source_sort_key`` is classified *identically* (both "fresh") by the real -convergence path (``convergence_stages._stale_session_profile_ids``) and the -real ops-repair path (``repair._targeted_session_insight_rebuild_ids``) when -``source_updated_at`` agrees with the session; (2) the same fixture is -classified identically as "stale" by both paths when ``source_updated_at`` -disagrees. It would fail against the pre-fix repair predicate, which ignored -``source_updated_at`` entirely and used ``source_sort_key`` unconditionally. -""" - -from __future__ import annotations - -import sqlite3 -from pathlib import Path -from types import SimpleNamespace - -import polylogue.daemon.convergence_stages as convergence_stages -import polylogue.storage.repair as repair -from polylogue.storage.insights.session.runtime import ( - SESSION_INSIGHT_MATERIALIZATION_TYPES, -) -from polylogue.storage.runtime import SESSION_INSIGHT_MATERIALIZER_VERSION - -_SESSION_ID = "ts-null-sort-key" -_UPDATED_AT_MS = 1_780_000_000_000 - - -def _build_fixture_db(path: Path, *, source_updated_at: str, source_sort_key: float) -> None: - """Minimal schema covering every table ``repair._targeted_session_insight_rebuild_ids`` joins.""" - - conn = sqlite3.connect(path) - try: - conn.executescript( - """ - CREATE TABLE sessions ( - session_id TEXT PRIMARY KEY, - sort_key_ms INTEGER, - updated_at_ms INTEGER - ); - CREATE TABLE session_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT, - work_event_count INTEGER NOT NULL DEFAULT 0, - phase_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE session_latency_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT - ); - CREATE TABLE session_work_events (session_id TEXT); - CREATE TABLE session_phases (session_id TEXT); - CREATE TABLE insight_materialization ( - insight_type TEXT, - session_id TEXT, - materializer_version INTEGER, - source_sort_key_ms INTEGER - ); - """ - ) - conn.execute( - "INSERT INTO sessions (session_id, sort_key_ms, updated_at_ms) VALUES (?, NULL, ?)", - (_SESSION_ID, _UPDATED_AT_MS), - ) - conn.execute( - """ - INSERT INTO session_profiles - (session_id, materializer_version, source_sort_key, source_updated_at) - VALUES (?, ?, ?, ?) - """, - (_SESSION_ID, SESSION_INSIGHT_MATERIALIZER_VERSION, source_sort_key, source_updated_at), - ) - conn.execute( - """ - INSERT INTO session_latency_profiles - (session_id, materializer_version, source_sort_key, source_updated_at) - VALUES (?, ?, ?, ?) - """, - (_SESSION_ID, SESSION_INSIGHT_MATERIALIZER_VERSION, source_sort_key, source_updated_at), - ) - # Every materialization type -- including "thread" -- fully stamped and - # sort-key-agreeing (source_sort_key_ms compares directly against - # sort_key_ms, which is NULL here, so COALESCE both sides to 0 for an - # exact NULL/NULL match). Every non-child session gets a "thread" stamp - # in production (``_materialize_thread_spine_sync`` stamps it for every - # member of the session's own singleton-or-larger thread, root included - # -- see ``polylogue/storage/insights/session/rebuild.py``), so leaving - # it unstamped here is a fixture gap, not a real "timeless session - # never gets threaded" scenario: it left repair's generic per-type - # ``NOT EXISTS`` check in ``_targeted_session_insight_rebuild_ids`` - # unconditionally true for "thread" (no row at all, regardless of sort - # key agreement), which is a different failure axis than the - # session-profile/session-latency-profile predicate this test isolates - # and was spuriously making these tests fail on that unrelated axis. - for insight_type in SESSION_INSIGHT_MATERIALIZATION_TYPES: - conn.execute( - "INSERT INTO insight_materialization (insight_type, session_id, materializer_version, " - "source_sort_key_ms) VALUES (?, ?, ?, NULL)", - (insight_type, _SESSION_ID, SESSION_INSIGHT_MATERIALIZER_VERSION), - ) - conn.commit() - finally: - conn.close() - - -def _run_convergence_pass(db_path: Path) -> list[str]: - conn = sqlite3.connect(db_path) - try: - return convergence_stages._stale_session_profile_ids(conn, [_SESSION_ID]) - finally: - conn.close() - - -def _run_repair_pass(db_path: Path) -> tuple[str, ...]: - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - try: - result = repair._targeted_session_insight_rebuild_ids(conn, SimpleNamespace()) - assert result is not None, "fixture triggered an unexpected archive-wide rebuild fallback" - return result - finally: - conn.close() - - -def test_null_sort_key_fresh_profile_agrees_between_convergence_and_repair(tmp_path: Path) -> None: - """source_updated_at matches the session: both paths must call it fresh. - - Pre-fix, repair's COALESCE-to-0.0 branch flagged this row stale purely - because ``source_sort_key`` (999.0) was nonzero — regardless of - ``source_updated_at`` agreement — while the converger correctly called it - fresh. That divergence is the bug this test pins. - """ - - db_path = tmp_path / "fresh.db" - agreeing_source_updated_at = str(_UPDATED_AT_MS // 1000) - _build_fixture_db(db_path, source_updated_at=agreeing_source_updated_at, source_sort_key=999.0) - - convergence_stale = _run_convergence_pass(db_path) - repair_stale = _run_repair_pass(db_path) - - assert convergence_stale == [], "converger incorrectly considers the fresh NULL-sort-key profile stale" - assert _SESSION_ID not in repair_stale, "repair incorrectly considers the fresh NULL-sort-key profile stale" - - -def test_null_sort_key_stale_profile_agrees_between_convergence_and_repair(tmp_path: Path) -> None: - """source_updated_at disagrees with the session: both paths must call it stale.""" - - db_path = tmp_path / "stale.db" - disagreeing_source_updated_at = str((_UPDATED_AT_MS // 1000) - 3600) - _build_fixture_db(db_path, source_updated_at=disagreeing_source_updated_at, source_sort_key=0.0) - - convergence_stale = _run_convergence_pass(db_path) - repair_stale = _run_repair_pass(db_path) - - assert convergence_stale == [_SESSION_ID] - assert _SESSION_ID in repair_stale - - -def test_repair_selects_zero_rows_immediately_after_convergence_agrees(tmp_path: Path) -> None: - """Idempotence: once the converger calls a NULL-sort-key row fresh, repair must not re-flag it. - - This is the concrete manifestation of the bug's "repeated repair churn" - consequence — a converged archive must not oscillate between the two - passes. - """ - - db_path = tmp_path / "idempotent.db" - agreeing_source_updated_at = str(_UPDATED_AT_MS // 1000) - _build_fixture_db(db_path, source_updated_at=agreeing_source_updated_at, source_sort_key=999.0) - - assert _run_convergence_pass(db_path) == [] - assert _run_repair_pass(db_path) == () From 93570e8e49d76056ba3252cc44a80fa56ac3c965 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 04:22:44 +0200 Subject: [PATCH 04/11] chore: delete the declared-not-routed candidate-build operation contract The inactive-candidate construction operation and its domain check plan existed only for the retired rebuild engine; nothing routed them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- polylogue/maintenance/archive_verification.py | 2 +- polylogue/maintenance/domain_check_plan.py | 232 -------- polylogue/operations/__init__.py | 44 -- polylogue/operations/candidate_build.py | 560 ------------------ polylogue/operations/specs.py | 24 - .../maintenance/test_domain_check_plan.py | 61 -- tests/unit/operations/test_candidate_build.py | 270 --------- tests/unit/operations/test_specs.py | 1 - 8 files changed, 1 insertion(+), 1193 deletions(-) delete mode 100644 polylogue/maintenance/domain_check_plan.py delete mode 100644 polylogue/operations/candidate_build.py delete mode 100644 tests/unit/maintenance/test_domain_check_plan.py delete mode 100644 tests/unit/operations/test_candidate_build.py diff --git a/polylogue/maintenance/archive_verification.py b/polylogue/maintenance/archive_verification.py index f07bb0d3a8..61e9c10c3a 100644 --- a/polylogue/maintenance/archive_verification.py +++ b/polylogue/maintenance/archive_verification.py @@ -753,7 +753,7 @@ def archive_verification_migrated_owner_adapters( name="planner-stats", semantic_owner="daemon-index-health", applicable_routes=frozenset({_ROUTE_HEALTH_MEDIUM, _ROUTE_LIVE}), - production_route="candidate build completion", + production_route="daemon index health", population=("index.db.sqlite_stat1",), owned_reference="test_missing_sqlite_stat1_is_warning_not_error", check=lambda: _check_planner_stats(archive_root, sample_limit, index_path=index_path_override), diff --git a/polylogue/maintenance/domain_check_plan.py b/polylogue/maintenance/domain_check_plan.py deleted file mode 100644 index f0cbbce642..0000000000 --- a/polylogue/maintenance/domain_check_plan.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Compile the finite exact-source/candidate check plan. - -This is deliberately a projection, not a catalogue. Domain owners remain -responsible for the predicate, oracle, production route, and result. The -compiler only freezes the membership and bindings needed by a bounded run. -""" - -from __future__ import annotations - -import hashlib -import json -import re -from collections.abc import Iterable, Mapping -from dataclasses import dataclass -from typing import Literal - -from polylogue.core.json import JSONDocument, json_document -from polylogue.core.outcomes import OutcomeOwner -from polylogue.operations.specs import RUNTIME_OPERATION_SPECS - -PlanPhase = Literal["source", "candidate"] -_DIGEST = re.compile(r"^[0-9a-f]{64}$") -_KNOWN_OPERATIONS = frozenset(spec.name for spec in RUNTIME_OPERATION_SPECS) - - -class DomainCheckPlanError(ValueError): - """Raised when declarations cannot safely authorize a bounded run.""" - - -@dataclass(frozen=True, slots=True) -class DomainCheckDeclaration: - """The plan-facing facts naturally owned by one domain law.""" - - identity: str - version: int - owner_operation: str - phase: PlanPhase - denominator: str - target_bindings: tuple[str, ...] - production_route: str - oracle_reference: str - candidate_applicability: Literal["required", "not-applicable"] = "required" - - def to_dict(self) -> JSONDocument: - return json_document( - { - "identity": self.identity, - "version": self.version, - "owner_operation": self.owner_operation, - "phase": self.phase, - "denominator": self.denominator, - "target_bindings": list(self.target_bindings), - "production_route": self.production_route, - "oracle_reference": self.oracle_reference, - "candidate_applicability": self.candidate_applicability, - } - ) - - -@dataclass(frozen=True, slots=True) -class DomainCheckPlanRow: - """A minimal immutable plan row; semantic results live with the owner.""" - - identity: str - version: int - owner_operation: str - phase: PlanPhase - denominator: str - target_bindings: tuple[str, ...] - - def to_dict(self) -> JSONDocument: - return json_document( - { - "identity": self.identity, - "version": self.version, - "owner_operation": self.owner_operation, - "phase": self.phase, - "denominator": self.denominator, - "target_bindings": list(self.target_bindings), - } - ) - - -@dataclass(frozen=True, slots=True) -class DomainCheckPlan: - """Deterministically ordered, disposable source/candidate run evidence.""" - - phase: PlanPhase - rows: tuple[DomainCheckPlanRow, ...] - - @property - def member_identities(self) -> tuple[str, ...]: - return tuple(f"{row.identity}@{row.version}" for row in self.rows) - - def to_dict(self) -> JSONDocument: - return json_document({"phase": self.phase, "rows": [row.to_dict() for row in self.rows]}) - - @property - def digest(self) -> str: - payload = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() - return hashlib.sha256(payload).hexdigest() - - @classmethod - def from_dict(cls, payload: Mapping[str, object]) -> DomainCheckPlan: - phase = payload.get("phase") - rows = payload.get("rows") - if phase not in {"source", "candidate"} or not isinstance(rows, list): - raise DomainCheckPlanError("malformed domain check plan") - parsed: list[DomainCheckPlanRow] = [] - for raw in rows: - if not isinstance(raw, Mapping): - raise DomainCheckPlanError("plan row must be an object") - try: - parsed.append( - DomainCheckPlanRow( - identity=str(raw["identity"]), - version=int(raw["version"]), - owner_operation=str(raw["owner_operation"]), - phase=raw["phase"], - denominator=str(raw["denominator"]), - target_bindings=tuple(str(item) for item in raw["target_bindings"]), - ) - ) - except (KeyError, TypeError, ValueError) as exc: - raise DomainCheckPlanError(f"malformed plan row: {exc}") from exc - plan = compile_domain_check_plan(parsed, phase=phase) - if plan.to_dict() != json_document(dict(payload)): - raise DomainCheckPlanError("plan is not canonical") - return plan - - -def _declaration_row(declaration: DomainCheckDeclaration, phase: PlanPhase) -> DomainCheckPlanRow: - if declaration.phase != phase: - raise DomainCheckPlanError( - f"phase/target mismatch for {declaration.identity!r}: {declaration.phase} != {phase}" - ) - return DomainCheckPlanRow( - identity=declaration.identity, - version=declaration.version, - owner_operation=declaration.owner_operation, - phase=phase, - denominator=declaration.denominator, - target_bindings=tuple(sorted(declaration.target_bindings)), - ) - - -def compile_domain_check_plan( - declarations: Iterable[DomainCheckDeclaration | DomainCheckPlanRow], - *, - phase: PlanPhase, -) -> DomainCheckPlan: - """Validate and compile declarations independently of declaration order.""" - - rows: list[DomainCheckPlanRow] = [] - seen: set[tuple[str, int]] = set() - for declaration in declarations: - if isinstance(declaration, DomainCheckPlanRow): - row = declaration - if row.phase != phase: - raise DomainCheckPlanError(f"phase/target mismatch for {row.identity!r}") - else: - if not declaration.identity.strip() or declaration.version <= 0: - raise DomainCheckPlanError("check identity and positive version are required") - if declaration.owner_operation not in _KNOWN_OPERATIONS: - raise DomainCheckPlanError(f"unknown owning operation: {declaration.owner_operation}") - if not declaration.production_route.strip() or not declaration.oracle_reference.strip(): - raise DomainCheckPlanError(f"check {declaration.identity!r} has no production owner/oracle") - if not declaration.denominator.strip(): - raise DomainCheckPlanError(f"check {declaration.identity!r} has no denominator") - if not declaration.target_bindings: - raise DomainCheckPlanError(f"check {declaration.identity!r} has no target binding") - if declaration.candidate_applicability == "not-applicable" and phase == "candidate": - continue - if declaration.candidate_applicability not in {"required", "not-applicable"}: - raise DomainCheckPlanError(f"invalid non-applicability for {declaration.identity!r}") - row = _declaration_row(declaration, phase) - key = (row.identity, row.version) - if not row.identity.strip() or row.version <= 0: - raise DomainCheckPlanError("check identity and positive version are required") - if key in seen: - raise DomainCheckPlanError(f"duplicate check identity/version: {row.identity}@{row.version}") - if row.owner_operation not in _KNOWN_OPERATIONS: - raise DomainCheckPlanError(f"unknown owning operation: {row.owner_operation}") - if not row.denominator.strip() or not row.target_bindings: - raise DomainCheckPlanError(f"plan row {row.identity!r} has a weak denominator or target") - seen.add(key) - rows.append(row) - rows.sort(key=lambda row: (row.identity, row.version, row.owner_operation, row.phase, row.target_bindings)) - return DomainCheckPlan(phase=phase, rows=tuple(rows)) - - -def declarations_from_outcome_owners( - owners: Iterable[OutcomeOwner], *, phase: PlanPhase -) -> tuple[DomainCheckDeclaration, ...]: - """Adapt existing domain-owned outcome declarations without storing them.""" - result: list[DomainCheckDeclaration] = [] - for owner in owners: - name = owner.name - routes = owner.applicable_routes - candidate_routes = { - "reindex-index-candidate", - "reindex-cross-tier-candidate", - "reindex-canary-candidate", - "corpus-fidelity", - } - if phase == "candidate" and not candidate_routes.intersection(routes): - continue - result.append( - DomainCheckDeclaration( - identity=name, - version=1, - owner_operation="candidate-build", - phase=phase, - denominator="|".join(owner.population), - target_bindings=tuple( - sorted(route for route in routes if phase == "source" or route in candidate_routes) - ), - production_route=owner.production_route, - oracle_reference=owner.owned_reference, - ) - ) - return tuple(result) - - -__all__ = [ - "DomainCheckDeclaration", - "DomainCheckPlan", - "DomainCheckPlanError", - "DomainCheckPlanRow", - "compile_domain_check_plan", - "declarations_from_outcome_owners", -] diff --git a/polylogue/operations/__init__.py b/polylogue/operations/__init__.py index de74a75a18..4a6f17a316 100644 --- a/polylogue/operations/__init__.py +++ b/polylogue/operations/__init__.py @@ -19,22 +19,6 @@ ArchiveStats, CompletionAggregate, ) - from .candidate_build import ( - CandidateBuildBudget, - CandidateBuildError, - CandidateBuildGeneration, - CandidateBuildObligation, - CandidateBuildPlan, - CandidateBuildPlanningContext, - CandidateBuildProgress, - CandidateBuildReceipt, - CandidateBuildRequest, - CandidateBuildResult, - CandidateBuildWireRequest, - SourceSeal, - lower_candidate_build_wire, - plan_candidate_build, - ) from .import_contracts import ( ImportOperation, RawFailureSample, @@ -67,20 +51,6 @@ def __getattr__(name: str) -> object: "ImportAck": (".import_operations", "ImportAck"), "ImportRequest": (".import_operations", "ImportRequest"), "OperationFollowUp": (".operation_contract", "OperationFollowUp"), - "CandidateBuildBudget": (".candidate_build", "CandidateBuildBudget"), - "CandidateBuildError": (".candidate_build", "CandidateBuildError"), - "CandidateBuildGeneration": (".candidate_build", "CandidateBuildGeneration"), - "CandidateBuildObligation": (".candidate_build", "CandidateBuildObligation"), - "CandidateBuildPlan": (".candidate_build", "CandidateBuildPlan"), - "CandidateBuildPlanningContext": (".candidate_build", "CandidateBuildPlanningContext"), - "CandidateBuildProgress": (".candidate_build", "CandidateBuildProgress"), - "CandidateBuildReceipt": (".candidate_build", "CandidateBuildReceipt"), - "CandidateBuildRequest": (".candidate_build", "CandidateBuildRequest"), - "CandidateBuildResult": (".candidate_build", "CandidateBuildResult"), - "CandidateBuildWireRequest": (".candidate_build", "CandidateBuildWireRequest"), - "SourceSeal": (".candidate_build", "SourceSeal"), - "lower_candidate_build_wire": (".candidate_build", "lower_candidate_build_wire"), - "plan_candidate_build": (".candidate_build", "plan_candidate_build"), "OperationStatus": (".operation_status", "OperationStatus"), "OperationCatalog": (".specs", "OperationCatalog"), "OperationKind": (".specs", "OperationKind"), @@ -100,17 +70,6 @@ def __getattr__(name: str) -> object: __all__ = [ "ArchiveDebtInsight", "ArchiveStats", - "CandidateBuildBudget", - "CandidateBuildError", - "CandidateBuildGeneration", - "CandidateBuildObligation", - "CandidateBuildPlan", - "CandidateBuildPlanningContext", - "CandidateBuildProgress", - "CandidateBuildReceipt", - "CandidateBuildRequest", - "CandidateBuildResult", - "CandidateBuildWireRequest", "CompletionAggregate", "ImportAck", "ImportOperation", @@ -122,10 +81,7 @@ def __getattr__(name: str) -> object: "OperationStatus", "RawFailureSample", "SafetyGuard", - "SourceSeal", "bounded_failure_samples", "build_declared_operation_catalog", "build_runtime_operation_catalog", - "lower_candidate_build_wire", - "plan_candidate_build", ] diff --git a/polylogue/operations/candidate_build.py b/polylogue/operations/candidate_build.py deleted file mode 100644 index d0fef0fcc7..0000000000 --- a/polylogue/operations/candidate_build.py +++ /dev/null @@ -1,560 +0,0 @@ -"""The capability-negative candidate generation operation. - -Candidate construction is deliberately a small operation contract. The -request is the authenticated *what* of a build; the daemon supplies all -physical and execution facts when it plans the request. Keeping those two -sets of facts separate is important: a client may ask for an inactive -candidate, but it must not be able to choose a pathname, generation id, -budget, acceptance profile, or lifecycle transition. - -This module is also the wire boundary for the operation. Its Pydantic -models reject unknown fields and its identity digest is computed from a -canonical representation, so CLI, daemon, HTTP, and MCP adapters can all -lower to exactly the same request. -""" - -from __future__ import annotations - -import hashlib -import json -import re -from collections.abc import Callable, Mapping -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Literal - -from pydantic import ConfigDict, Field, StrictInt, field_validator, model_validator - -from polylogue.core.enums import OperationStatus -from polylogue.core.json import JSONDocument, json_document -from polylogue.operations.specs import OperationKind -from polylogue.surfaces.payloads import SurfacePayloadModel - -if TYPE_CHECKING: - from polylogue.storage.index_generation import IndexGeneration - -CANDIDATE_BUILD_OPERATION: Literal["candidate-build"] = "candidate-build" -CANDIDATE_BUILD_PROTOCOL: Literal["polylogue.candidate-build/v1"] = "polylogue.candidate-build/v1" -CANDIDATE_BUILD_CLASS: Literal["inactive-candidate"] = "inactive-candidate" -CANDIDATE_BUILD_POLICY: Literal["inactive-candidate-v1"] = "inactive-candidate-v1" - -_IDENTIFIER_RE = re.compile(r"^[^/\\\x00]+$") -_DIGEST_RE = re.compile(r"^[0-9a-fA-F]{64}$") -_PLAN_STATUSES = frozenset({OperationStatus.ACCEPTED, OperationStatus.PENDING, OperationStatus.RUNNING}) - - -def _identifier(value: object, *, label: str) -> str: - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{label} must be a non-empty string") - value = value.strip() - if not _IDENTIFIER_RE.fullmatch(value) or value in {".", ".."}: - raise ValueError(f"{label} must be an opaque identifier, not a path") - return value - - -def _ordered_unique(values: tuple[str, ...], *, label: str) -> tuple[str, ...]: - normalized = tuple(_identifier(value, label=label) for value in values) - if not normalized: - raise ValueError(f"{label} must not be empty") - if len(set(normalized)) != len(normalized): - raise ValueError(f"{label} must contain unique values") - return tuple(sorted(normalized)) - - -class SourceSeal(SurfacePayloadModel): - """The source authority consumed by a candidate build. - - These are identities, not locators. In particular, no source pathname - is accepted. The daemon constructs this value from its authenticated - source descriptor and compares it again at execution boundaries. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - archive_identity: str - source_identity: str - source_snapshot: str - source_schema_version: StrictInt = Field(gt=0) - cut_identity: str - candidate_manifest_digest: str - carry_forward_manifest_digest: str - - @field_validator("archive_identity", "source_identity", "source_snapshot") - @classmethod - def validate_identity(cls, value: str, info: Any) -> str: - return _identifier(value, label=info.field_name) - - @field_validator("cut_identity", "candidate_manifest_digest", "carry_forward_manifest_digest") - @classmethod - def validate_digest(cls, value: str, info: Any) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError(f"{info.field_name} must be a SHA-256 hex digest") - return value.lower() - - @property - def digest(self) -> str: - return _sha256(self.model_dump(mode="json")) - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -class CandidateBuildRequest(SurfacePayloadModel): - """Authenticated semantic identity for one inactive candidate build. - - The deliberately closed field set is the capability boundary. Physical - paths, generation IDs, selection/member IDs, resource knobs, callbacks, - arbitrary checks, and lifecycle verbs are not request fields and are - rejected by the shared ``extra='forbid'`` wire model. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - operation_kind: ClassVar[OperationKind] = OperationKind.MATERIALIZATION - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - source_seal: SourceSeal - package: str - code: str - schemas: tuple[str, ...] - parser_declarations: tuple[str, ...] - lowering_declarations: tuple[str, ...] - origin_declarations: tuple[str, ...] - recipe_version: str - semantic_version: str - check_plan_digest: str | None = None - check_plan_members: tuple[str, ...] = () - generation_policy: Literal["inactive-candidate-v1"] = CANDIDATE_BUILD_POLICY - build_class: Literal["inactive-candidate"] = CANDIDATE_BUILD_CLASS - - @field_validator("package", "code", "recipe_version", "semantic_version") - @classmethod - def validate_scalar_identity(cls, value: str, info: Any) -> str: - return _identifier(value, label=info.field_name) - - @field_validator("schemas", "parser_declarations", "lowering_declarations", "origin_declarations") - @classmethod - def validate_declarations(cls, value: tuple[str, ...], info: Any) -> tuple[str, ...]: - return _ordered_unique(value, label=info.field_name) - - @field_validator("check_plan_digest") - @classmethod - def validate_check_plan_digest(cls, value: str | None) -> str | None: - if value is not None and (_DIGEST_RE.fullmatch(value) is None): - raise ValueError("check_plan_digest must be a SHA-256 hex digest") - return value.lower() if value is not None else None - - @field_validator("check_plan_members") - @classmethod - def validate_check_plan_members(cls, value: tuple[str, ...]) -> tuple[str, ...]: - return _ordered_unique(value, label="check_plan_members") if value else () - - @model_validator(mode="after") - def validate_check_plan_binding(self) -> CandidateBuildRequest: - if self.check_plan_members and self.check_plan_digest is None: - raise ValueError("check_plan_members require check_plan_digest") - return self - - @classmethod - def from_dict(cls, raw: Mapping[str, object]) -> CandidateBuildRequest: - """Validate one untrusted request object at a surface boundary.""" - - return cls.model_validate(raw) - - def identity_document(self) -> JSONDocument: - """Return only semantic authority, with unordered declarations sorted.""" - - return json_document( - { - "operation": self.operation, - "source_seal": self.source_seal.to_dict(), - "package": self.package, - "code": self.code, - "schemas": sorted(self.schemas), - "parser_declarations": sorted(self.parser_declarations), - "lowering_declarations": sorted(self.lowering_declarations), - "origin_declarations": sorted(self.origin_declarations), - "recipe_version": self.recipe_version, - "semantic_version": self.semantic_version, - "check_plan_digest": self.check_plan_digest, - "check_plan_members": sorted(self.check_plan_members), - "generation_policy": self.generation_policy, - "build_class": self.build_class, - } - ) - - @property - def identity_digest(self) -> str: - return _sha256(self.identity_document()) - - def canonical_bytes(self) -> bytes: - return json.dumps(self.identity_document(), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( - "utf-8" - ) - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -class CandidateBuildBudget(SurfacePayloadModel): - """Server-selected planning budgets; never part of request identity.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - source_bytes: StrictInt = Field(ge=0) - work_units: StrictInt = Field(ge=0) - memory_bytes: StrictInt = Field(ge=0) - - -class CandidateBuildObligation(SurfacePayloadModel): - """One server-owned obligation in the current candidate plan.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - name: str - required: bool = True - - @field_validator("name") - @classmethod - def validate_name(cls, value: str) -> str: - return _identifier(value, label="obligation name") - - -class CandidateBuildGeneration(SurfacePayloadModel): - """The narrow generation identity exposed after server-side resolution.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - generation_id: str - owner_id: str - archive_root: str - index_path: str - state: Literal["inactive"] = "inactive" - - @field_validator("generation_id", "owner_id") - @classmethod - def validate_ids(cls, value: str, info: Any) -> str: - return _identifier(value, label=info.field_name) - - @field_validator("archive_root", "index_path") - @classmethod - def validate_paths(cls, value: str, info: Any) -> str: - if not value or not Path(value).is_absolute(): - raise ValueError(f"{info.field_name} must be an absolute server-resolved path") - return value - - -class CandidateBuildPlan(SurfacePayloadModel): - """Server-resolved plan for the validated request. - - The physical roots and target generation are intentionally introduced - here, after request validation. ``preview`` changes no authority: it is - only a projection of this same plan shape. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) - - protocol: Literal["polylogue.candidate-build/v1"] = CANDIDATE_BUILD_PROTOCOL - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - request_digest: str - archive_root: str - target_generation: CandidateBuildGeneration - budget: CandidateBuildBudget - obligations: tuple[CandidateBuildObligation, ...] - preview: bool = False - authority: Literal["inactive-only"] = "inactive-only" - - @field_validator("request_digest") - @classmethod - def validate_request_digest(cls, value: str) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError("request_digest must be a SHA-256 hex digest") - return value.lower() - - @field_validator("archive_root") - @classmethod - def validate_archive_root(cls, value: str) -> str: - if not value or not Path(value).is_absolute(): - raise ValueError("archive_root is a server-resolved absolute path") - return value - - @model_validator(mode="after") - def validate_target(self) -> CandidateBuildPlan: - if self.target_generation.state != "inactive": - raise ValueError("candidate target generation must be inactive") - return self - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - @property - def plan_digest(self) -> str: - return _sha256(self.to_dict()) - - @classmethod - def from_dict(cls, raw: Mapping[str, object]) -> CandidateBuildPlan: - return cls.model_validate(raw) - - -class CandidateBuildProgress(SurfacePayloadModel): - """Bounded progress for one operation, not a scheduling command.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - operation_id: str - request_digest: str - generation_id: str - status: OperationStatus - processed_units: StrictInt = Field(ge=0) - total_units: StrictInt = Field(ge=0) - processed_bytes: StrictInt = Field(ge=0) - total_bytes: StrictInt = Field(ge=0) - - @field_validator("operation_id", "generation_id") - @classmethod - def validate_identifiers(cls, value: str, info: Any) -> str: - return _identifier(value, label=info.field_name) - - @field_validator("request_digest") - @classmethod - def validate_digest(cls, value: str) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError("request_digest must be a SHA-256 hex digest") - return value.lower() - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -class CandidateBuildResult(SurfacePayloadModel): - """Successful candidate-build result with an explicit inactive boundary.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - status: Literal["completed"] = "completed" - request_digest: str - plan_digest: str - generation: CandidateBuildGeneration - source_seal_digest: str - processed_units: StrictInt = Field(ge=0) - processed_bytes: StrictInt = Field(ge=0) - lifecycle: Literal["inactive"] = "inactive" - - @field_validator("request_digest", "plan_digest", "source_seal_digest") - @classmethod - def validate_digests(cls, value: str) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError("candidate result identities must be SHA-256 hex digests") - return value.lower() - - @model_validator(mode="after") - def validate_inactive_generation(self) -> CandidateBuildResult: - if self.generation.state != "inactive": - raise ValueError("candidate result cannot contain an active generation") - return self - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -class CandidateBuildError(SurfacePayloadModel): - """Typed failure that cannot grant a lifecycle capability.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - status: Literal["failed", "interrupted"] - code: Literal[ - "invalid_request", - "source_changed", - "generation_conflict", - "capacity_blocked", - "lineage_invalid", - "execution_failed", - ] - message: str - request_digest: str - generation_id: str | None = None - retryable: bool = False - - @field_validator("request_digest") - @classmethod - def validate_request_digest(cls, value: str) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError("request_digest must be a SHA-256 hex digest") - return value.lower() - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -class CandidateBuildReceipt(SurfacePayloadModel): - """Terminal, replayable evidence for candidate construction.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - protocol: Literal["polylogue.candidate-build/v1"] = CANDIDATE_BUILD_PROTOCOL - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - operation_id: str - request_digest: str - plan_digest: str - status: Literal["completed", "failed", "interrupted"] - result: CandidateBuildResult | None = None - error: CandidateBuildError | None = None - source_seal_digest: str - generation_id: str - - @field_validator("operation_id", "generation_id") - @classmethod - def validate_identifiers(cls, value: str, info: Any) -> str: - return _identifier(value, label=info.field_name) - - @field_validator("request_digest", "plan_digest", "source_seal_digest") - @classmethod - def validate_digests(cls, value: str) -> str: - if _DIGEST_RE.fullmatch(value) is None: - raise ValueError("candidate receipt identities must be SHA-256 hex digests") - return value.lower() - - @model_validator(mode="after") - def validate_terminal_shape(self) -> CandidateBuildReceipt: - if (self.status == "completed") != (self.result is not None and self.error is None): - raise ValueError("completed receipts require exactly one successful result") - if self.status != "completed" and (self.error is None or self.result is not None): - raise ValueError("failed receipts require exactly one typed error") - if self.result is not None: - if self.result.generation.state != "inactive": - raise ValueError("candidate receipt cannot attest activation") - if self.result.request_digest != self.request_digest: - raise ValueError("candidate receipt/result request identity mismatch") - if self.result.plan_digest != self.plan_digest: - raise ValueError("candidate receipt/result plan identity mismatch") - if self.result.source_seal_digest != self.source_seal_digest: - raise ValueError("candidate receipt/result source seal mismatch") - if self.result.generation.generation_id != self.generation_id: - raise ValueError("candidate receipt/result generation identity mismatch") - if self.error is not None and self.error.request_digest != self.request_digest: - raise ValueError("candidate receipt/error request identity mismatch") - return self - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - @classmethod - def from_dict(cls, raw: Mapping[str, object]) -> CandidateBuildReceipt: - return cls.model_validate(raw) - - -class CandidateBuildWireRequest(SurfacePayloadModel): - """Strict protocol envelope shared by every adapter.""" - - model_config = ConfigDict(extra="forbid", frozen=True) - - protocol: Literal["polylogue.candidate-build/v1"] = CANDIDATE_BUILD_PROTOCOL - operation: Literal["candidate-build"] = CANDIDATE_BUILD_OPERATION - request: CandidateBuildRequest - - @classmethod - def from_dict(cls, raw: Mapping[str, object]) -> CandidateBuildWireRequest: - return cls.model_validate(raw) - - def to_dict(self) -> JSONDocument: - return json_document(self.model_dump(mode="json")) - - -@dataclass(frozen=True, slots=True) -class CandidateBuildPlanningContext: - """Daemon-owned facts used to resolve a request into a plan.""" - - archive_root: Path - source_seal: SourceSeal - generation: IndexGeneration - budget: CandidateBuildBudget - obligations: tuple[CandidateBuildObligation, ...] = () - expected_check_plan_digest: str | None = None - expected_check_plan_members: tuple[str, ...] = () - recompute_source_seal: Callable[[], SourceSeal] | None = None - - def plan(self, request: CandidateBuildRequest, *, preview: bool = False) -> CandidateBuildPlan: - if self.recompute_source_seal is not None and self.recompute_source_seal() != self.source_seal: - raise ValueError("candidate source cut is stale") - if request.source_seal != self.source_seal: - raise ValueError("candidate request source seal is stale") - if self.expected_check_plan_digest is not None: - if request.check_plan_digest != self.expected_check_plan_digest: - raise ValueError("candidate request check plan is stale") - if tuple(request.check_plan_members) != tuple(sorted(self.expected_check_plan_members)): - raise ValueError("candidate request check plan members are stale") - if self.generation.state != "inactive": - raise ValueError("candidate target generation is not inactive") - if Path(self.generation.archive_root).absolute() != self.archive_root.absolute(): - raise ValueError("candidate generation belongs to a different archive root") - return CandidateBuildPlan( - request_digest=request.identity_digest, - archive_root=str(self.archive_root.absolute()), - target_generation=CandidateBuildGeneration( - generation_id=self.generation.generation_id, - owner_id=self.generation.owner_id, - archive_root=self.generation.archive_root, - index_path=self.generation.index_path, - ), - budget=self.budget, - obligations=tuple(sorted(self.obligations, key=lambda item: item.name)), - preview=preview, - ) - - -def plan_candidate_build( - request: CandidateBuildRequest, - context: CandidateBuildPlanningContext, - *, - preview: bool = False, -) -> CandidateBuildPlan: - """Resolve one plan; preview and execution use this identical path.""" - - return context.plan(request, preview=preview) - - -def lower_candidate_build_wire( - raw: Mapping[str, object], - *, - surface: Literal["cli", "daemon", "mcp", "api"], -) -> CandidateBuildRequest: - """Lower any public adapter to the canonical typed request. - - ``surface`` is an authorization label only; it cannot add fields or - capabilities. The operation declaration in ``specs.py`` remains the - source of the public surface list. - """ - - if surface not in {"cli", "daemon", "mcp", "api"}: - raise ValueError(f"candidate build is not available on surface {surface!r}") - return CandidateBuildWireRequest.from_dict(raw).request - - -def _sha256(payload: object) -> str: - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") - return hashlib.sha256(encoded).hexdigest() - - -__all__ = [ - "CANDIDATE_BUILD_CLASS", - "CANDIDATE_BUILD_OPERATION", - "CANDIDATE_BUILD_POLICY", - "CANDIDATE_BUILD_PROTOCOL", - "CandidateBuildBudget", - "CandidateBuildError", - "CandidateBuildGeneration", - "CandidateBuildObligation", - "CandidateBuildPlan", - "CandidateBuildPlanningContext", - "CandidateBuildProgress", - "CandidateBuildReceipt", - "CandidateBuildRequest", - "CandidateBuildResult", - "CandidateBuildWireRequest", - "SourceSeal", - "lower_candidate_build_wire", - "plan_candidate_build", -] diff --git a/polylogue/operations/specs.py b/polylogue/operations/specs.py index e71d947703..0fba6c3dad 100644 --- a/polylogue/operations/specs.py +++ b/polylogue/operations/specs.py @@ -146,30 +146,6 @@ def to_dict(self) -> JSONDocumentList: RUNTIME_OPERATION_SPECS: tuple[OperationSpec, ...] = ( - OperationSpec( - name="candidate-build", - kind=OperationKind.MATERIALIZATION, - description=( - "Build one source-sealed, resumable, inactive index candidate. The daemon resolves physical roots, " - "generation identity, obligations, and budgets; this operation never activates, accepts, or promotes." - ), - surfaces=("daemon", "cli", "mcp", "api"), - mutates_state=True, - previewable=True, - idempotent=True, - effects=("DbRead", "DbWrite"), - safety_guards=("write_role_required",), - executor_status="declared-not-routed", - resumable=True, - affected_tiers=("source", "index"), - idempotency="effect_key", - request_contract="polylogue.operations.candidate_build.CandidateBuildRequest", - plan_contract="polylogue.operations.candidate_build.CandidateBuildPlan", - progress_contract="polylogue.operations.candidate_build.CandidateBuildProgress", - result_contract="polylogue.operations.candidate_build.CandidateBuildResult", - error_contract="polylogue.operations.candidate_build.CandidateBuildError", - receipt_contract="polylogue.operations.candidate_build.CandidateBuildReceipt", - ), OperationSpec( name="acquire-raw-sessions", kind=OperationKind.MATERIALIZATION, diff --git a/tests/unit/maintenance/test_domain_check_plan.py b/tests/unit/maintenance/test_domain_check_plan.py deleted file mode 100644 index 3c1fec90d2..0000000000 --- a/tests/unit/maintenance/test_domain_check_plan.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import pytest - -from polylogue.maintenance.domain_check_plan import ( - DomainCheckDeclaration, - DomainCheckPlanError, - compile_domain_check_plan, -) - - -def _declaration(**changes: object) -> DomainCheckDeclaration: - values: dict[str, object] = { - "identity": "lineage", - "version": 1, - "owner_operation": "candidate-build", - "phase": "candidate", - "denominator": "index.db.session_links", - "target_bindings": ("reindex-index-candidate",), - "production_route": "index graph materialization", - "oracle_reference": "test_lineage_route", - } - values.update(changes) - return DomainCheckDeclaration(**values) # type: ignore[arg-type] - - -def test_compilation_is_order_independent_and_digest_binds_members() -> None: - first = _declaration(identity="z-check") - second = _declaration(identity="a-check") - left = compile_domain_check_plan([first, second], phase="candidate") - right = compile_domain_check_plan([second, first], phase="candidate") - assert left.to_dict() == right.to_dict() - assert left.member_identities == ("a-check@1", "z-check@1") - assert len(left.digest) == 64 - - -@pytest.mark.parametrize( - ("field", "value", "message"), - [ - ("owner_operation", "missing-operation", "unknown owning operation"), - ("denominator", "", "denominator"), - ("target_bindings", (), "target"), - ("production_route", "", "production owner"), - ("oracle_reference", "", "production owner"), - ], -) -def test_weak_declarations_fail_closed(field: str, value: object, message: str) -> None: - with pytest.raises(DomainCheckPlanError, match=message): - compile_domain_check_plan([_declaration(**{field: value})], phase="candidate") - - -def test_duplicate_identity_version_and_phase_mismatch_fail_closed() -> None: - with pytest.raises(DomainCheckPlanError, match="duplicate"): - compile_domain_check_plan([_declaration(), _declaration()], phase="candidate") - with pytest.raises(DomainCheckPlanError, match="phase/target"): - compile_domain_check_plan([_declaration(phase="source")], phase="candidate") - - -def test_not_applicable_candidate_declaration_is_not_a_plan_member() -> None: - plan = compile_domain_check_plan([_declaration(candidate_applicability="not-applicable")], phase="candidate") - assert plan.rows == () diff --git a/tests/unit/operations/test_candidate_build.py b/tests/unit/operations/test_candidate_build.py deleted file mode 100644 index c4fbb95868..0000000000 --- a/tests/unit/operations/test_candidate_build.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Contract and capability-negative tests for candidate construction.""" - -from __future__ import annotations - -from collections.abc import Mapping -from pathlib import Path - -import pytest -from pydantic import ValidationError - -from polylogue.core.enums import OperationStatus -from polylogue.core.json import JSONValue -from polylogue.operations import ( - CandidateBuildBudget, - CandidateBuildError, - CandidateBuildGeneration, - CandidateBuildObligation, - CandidateBuildPlanningContext, - CandidateBuildProgress, - CandidateBuildReceipt, - CandidateBuildRequest, - CandidateBuildResult, - CandidateBuildWireRequest, - SourceSeal, - build_runtime_operation_catalog, - lower_candidate_build_wire, - plan_candidate_build, -) -from polylogue.storage.index_generation import IndexGeneration - - -def _seal() -> SourceSeal: - return SourceSeal( - archive_identity="archive-identity", - source_identity="source-identity", - source_snapshot="source-snapshot", - source_schema_version=35, - cut_identity="0" * 64, - candidate_manifest_digest="1" * 64, - carry_forward_manifest_digest="2" * 64, - ) - - -def _request() -> CandidateBuildRequest: - return CandidateBuildRequest( - source_seal=_seal(), - package="polylogue-index", - code="git:abc123", - schemas=("index:35", "source:35"), - parser_declarations=("parser:codex:v1",), - lowering_declarations=("lowering:archive:v2",), - origin_declarations=("origin:codex",), - recipe_version="recipe-v3", - semantic_version="semantic-v4", - ) - - -def _generation(*, state: str = "inactive") -> IndexGeneration: - return IndexGeneration( - generation_id="gen-1", - owner_id="owner-1", - archive_root="/archive", - index_path="/archive/.index-generations/gen-1/index.db", - state=state, - created_at_ms=1, - ) - - -def test_candidate_plan_refuses_a_recomputed_cut_mismatch() -> None: - """Mutation: changing the cut after request construction must reject the candidate.""" - request = _request() - stale = request.source_seal.model_copy(update={"cut_identity": "f" * 64}) - context = CandidateBuildPlanningContext( - archive_root=Path("/archive"), - source_seal=request.source_seal, - generation=_generation(), - budget=CandidateBuildBudget(source_bytes=1, work_units=1, memory_bytes=1), - recompute_source_seal=lambda: stale, - ) - - with pytest.raises(ValueError, match="source cut is stale"): - plan_candidate_build(request, context) - - -def test_catalog_names_every_candidate_contract_type() -> None: - spec = build_runtime_operation_catalog().by_name()["candidate-build"] - contracts: tuple[tuple[str | None, str], ...] = ( - (spec.request_contract, "CandidateBuildRequest"), - (spec.plan_contract, "CandidateBuildPlan"), - (spec.progress_contract, "CandidateBuildProgress"), - (spec.result_contract, "CandidateBuildResult"), - (spec.error_contract, "CandidateBuildError"), - (spec.receipt_contract, "CandidateBuildReceipt"), - ) - for contract, suffix in contracts: - assert contract is not None - assert contract.endswith(suffix) - assert spec.surfaces == ("daemon", "cli", "mcp", "api") - - -def test_request_identity_is_order_independent_and_binds_every_authority_field() -> None: - request = _request() - reordered = request.model_copy( - update={ - "schemas": ("source:35", "index:35"), - "parser_declarations": ("parser:codex:v1",), - } - ) - assert reordered.identity_digest == request.identity_digest - - for field in ( - "source_seal", - "package", - "code", - "schemas", - "parser_declarations", - "lowering_declarations", - "origin_declarations", - "recipe_version", - "semantic_version", - ): - value = getattr(request, field) - if field == "source_seal": - value = value.model_copy(update={"source_snapshot": "changed"}) - elif isinstance(value, tuple): - value = (*value, "changed") - else: - value = f"{value}-changed" - assert request.model_copy(update={field: value}).identity_digest != request.identity_digest, field - - -@pytest.mark.parametrize( - "forbidden", - [ - {"archive_root": "/tmp/archive"}, - {"generation_id": "gen-1"}, - {"batch_size": 10}, - {"concurrency": 4}, - {"resource_budget": {"bytes": 10}}, - {"checks": ["anything"]}, - {"callback": "callable"}, - {"promote": True}, - {"activate": True}, - {"cleanup": True}, - {"source_mutation": True}, - ], -) -def test_request_rejects_forbidden_capabilities(forbidden: Mapping[str, JSONValue]) -> None: - payload = _request().to_dict() - payload.update(forbidden) - with pytest.raises(ValidationError): - CandidateBuildRequest.model_validate(payload) - - -def test_wire_protocol_rejects_drift_and_unknown_fields() -> None: - wire = CandidateBuildWireRequest(protocol="polylogue.candidate-build/v1", request=_request()) - payload = wire.to_dict() - assert lower_candidate_build_wire(payload, surface="cli") == _request() - with pytest.raises(ValidationError): - CandidateBuildWireRequest.model_validate({**payload, "protocol": "polylogue.candidate-build/v2"}) - with pytest.raises(ValidationError): - CandidateBuildWireRequest.model_validate({**payload, "unexpected": True}) - with pytest.raises(ValidationError): - CandidateBuildRequest.model_validate({**_request().to_dict(), "unexpected": True}) - - -def test_plan_is_server_resolved_and_preview_does_not_grant_lifecycle_authority() -> None: - context = CandidateBuildPlanningContext( - archive_root=Path("/archive"), - source_seal=_seal(), - generation=_generation(), - budget=CandidateBuildBudget(source_bytes=100, work_units=4, memory_bytes=1024), - obligations=(CandidateBuildObligation(name="lineage"), CandidateBuildObligation(name="source")), - ) - preview = plan_candidate_build(_request(), context, preview=True) - execute = plan_candidate_build(_request(), context) - assert preview.model_dump() | {"preview": False} == execute.model_dump() - assert preview.target_generation.state == "inactive" - assert preview.authority == "inactive-only" - assert preview.target_generation.index_path.startswith("/") - - -def test_plan_rejects_stale_seal_and_active_target() -> None: - context = CandidateBuildPlanningContext( - archive_root=Path("/archive"), - source_seal=_seal(), - generation=_generation(), - budget=CandidateBuildBudget(source_bytes=1, work_units=1, memory_bytes=1), - ) - with pytest.raises(ValueError, match="source seal"): - plan_candidate_build( - _request().model_copy(update={"source_seal": _seal().model_copy(update={"source_snapshot": "new"})}), - context, - ) - with pytest.raises(ValueError, match="inactive"): - plan_candidate_build( - _request(), - CandidateBuildPlanningContext( - archive_root=context.archive_root, - source_seal=context.source_seal, - generation=_generation(state="active"), - budget=context.budget, - ), - ) - - -def test_progress_and_receipt_cannot_attest_activation() -> None: - request = _request() - generation = CandidateBuildGeneration( - generation_id="gen-1", - owner_id="owner-1", - archive_root="/archive", - index_path="/archive/gen-1/index.db", - ) - progress = CandidateBuildProgress( - operation_id="operation-1", - request_digest=request.identity_digest, - generation_id=generation.generation_id, - status=OperationStatus.RUNNING, - processed_units=0, - total_units=1, - processed_bytes=0, - total_bytes=1, - ) - assert progress.status is OperationStatus.RUNNING - result = CandidateBuildResult( - request_digest=request.identity_digest, - plan_digest="a" * 64, - generation=generation, - source_seal_digest=request.source_seal.digest, - processed_units=1, - processed_bytes=1, - ) - receipt = CandidateBuildReceipt( - operation_id="operation-1", - request_digest=request.identity_digest, - plan_digest=result.plan_digest, - status="completed", - result=result, - source_seal_digest=result.source_seal_digest, - generation_id=generation.generation_id, - ) - assert receipt.result is not None and receipt.result.lifecycle == "inactive" - with pytest.raises(ValidationError): - CandidateBuildResult.model_validate( - {**result.to_dict(), "generation": {**generation.model_dump(), "state": "active"}} - ) - with pytest.raises(ValidationError): - CandidateBuildReceipt.model_validate({**receipt.to_dict(), "promoted": True}) - - -def test_typed_failure_is_not_a_success_receipt() -> None: - request = _request() - error = CandidateBuildError( - status="failed", - code="source_changed", - message="source seal changed", - request_digest=request.identity_digest, - retryable=True, - ) - with pytest.raises(ValidationError): - CandidateBuildReceipt( - operation_id="operation-1", - request_digest=request.identity_digest, - plan_digest="b" * 64, - status="completed", - error=error, - source_seal_digest=request.source_seal.digest, - generation_id="gen-1", - ) diff --git a/tests/unit/operations/test_specs.py b/tests/unit/operations/test_specs.py index 1d03db0027..a516be8292 100644 --- a/tests/unit/operations/test_specs.py +++ b/tests/unit/operations/test_specs.py @@ -12,7 +12,6 @@ def test_runtime_operation_catalog_covers_the_current_runtime_paths() -> None: specs = build_runtime_operation_catalog().by_name() assert set(specs) == { - "candidate-build", "acquire-raw-sessions", "plan-validation-backlog", "plan-parse-backlog", From 5a6eed0c08f6341a10b4cf9f2f9706b0eb0da63f Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 12:32:08 +0200 Subject: [PATCH 05/11] wip: checkpoint of interrupted lane repair-engine-delete Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- tests/conftest.py | 16 +- tests/infra/convergence_harness.py | 35 +- tests/infra/corpus_program.py | 117 +- tests/infra/excluded_cursor_live_proof.py | 30 +- tests/infra/reindex_campaign.py | 575 ------- tests/infra/storage_records.py | 20 +- tests/infra/test_corpus_program.py | 91 -- tests/property/test_inferred_corpus_loop.py | 42 +- .../unit/cli/test_archive_maintenance_cli.py | 1073 ------------- tests/unit/cli/test_check.py | 211 +-- .../cli/test_check_rendering_plain_runtime.py | 60 +- tests/unit/cli/test_check_support_runtime.py | 96 +- tests/unit/cli/test_convergence_feedback.py | 31 +- tests/unit/cli/test_paths.py | 86 - .../core/test_config_resolution_regression.py | 4 +- tests/unit/daemon/test_daemon_cli.py | 472 +----- .../unit/daemon/test_daemon_http_contracts.py | 43 - .../unit/daemon/test_daemon_http_security.py | 4 - tests/unit/daemon/test_health_check_paths.py | 2 - .../daemon/test_http_write_coordination.py | 65 - tests/unit/daemon/test_metrics_endpoint.py | 22 - tests/unit/daemon/test_parse_prefetch.py | 4 +- ...materialization_parse_stage_equivalence.py | 12 +- tests/unit/daemon/test_route_contracts.py | 58 - tests/unit/daemon/test_web_auth.py | 3 - .../unit/pipeline/test_differential_paths.py | 56 - tests/unit/sources/test_live_batch_support.py | 6 +- tests/unit/sources/test_source_snapshot.py | 21 - .../storage/test_archive_tiers_archive.py | 2 +- .../test_browser_capture_origin_repair.py | 141 +- .../test_duplicate_raw_identity_repair.py | 8 +- .../storage/test_quarantine_repair_budget.py | 2 +- .../test_quarantined_accepted_raw_repair.py | 2 +- .../unit/storage/test_raw_authority_ledger.py | 111 +- tests/unit/storage/test_raw_convergence.py | 1413 ++--------------- tests/unit/storage/test_rebuild_complexity.py | 18 +- .../storage/test_schema_policy_contracts.py | 2 +- tests/unit/test_sqlite_connection_hygiene.py | 27 - 38 files changed, 335 insertions(+), 4646 deletions(-) delete mode 100644 tests/infra/reindex_campaign.py diff --git a/tests/conftest.py b/tests/conftest.py index 6ff74ea4fd..78434d1d41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -76,13 +76,13 @@ def _file_batch(path: Path, count: int) -> int: def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: - """Apply suite-wide timeout and bounded-memory execution contracts. + """Apply suite-wide file-batch selection and timeout contracts. Pluggy only injects hook arguments that are required parameters; a parameter with a default is treated as optional and silently omitted, which previously made pytest call this hook with both arguments as - ``None`` and disabled file-batch deselection, the rebuild-index load - groups, and timeout-marker validation. Keep both parameters required. + ``None`` and disabled file-batch deselection and timeout-marker + validation. Keep both parameters required. """ from tests.infra.timeout_policy import timeout_marker_error @@ -103,16 +103,6 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item config.hook.pytest_deselected(items=deselected) for item in items: - # A merged-head 12-worker census measured 0.5-0.9 GiB of private - # memory per worker while rebuild-index tests from the same files ran - # concurrently, producing a 14.6 GiB cgroup peak. Keep file-local - # fixture isolation, but distribute this family over four stable - # loadgroups so unrelated tests can still use every configured worker. - item_path = Path(str(item.path)) - if item_path.parent.name == "maintenance" and item_path.name.startswith("test_rebuild_index_"): - bucket = zlib.crc32(item_path.name.encode()) % 4 - item.add_marker(pytest.mark.xdist_group(name=f"rebuild-index-memory-{bucket}")) - marker = item.get_closest_marker("timeout") if marker is None: continue diff --git a/tests/infra/convergence_harness.py b/tests/infra/convergence_harness.py index fe0c142c23..991e7c7aa8 100644 --- a/tests/infra/convergence_harness.py +++ b/tests/infra/convergence_harness.py @@ -21,7 +21,7 @@ from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import TYPE_CHECKING, cast +from typing import cast import polylogue.daemon.convergence_stages as convergence_stages import polylogue.pipeline.services.ingest_batch._core as ingest_batch_core @@ -57,9 +57,6 @@ compose_quarantined_head_arrangement, ) -if TYPE_CHECKING: - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt - SqlValue = str | int | float | bytes | None FactRow = tuple[SqlValue, ...] @@ -165,35 +162,6 @@ def build_converged_archive( return archive -def rebuild_retained_raw_index(archive: ConvergenceArchive | Path) -> RebuildIndexReceipt: - """Run the production source.db-retained reindex route for this archive.""" - from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - from tests.infra.rebuild_receipt import write_valid_rebuild_receipt - - root = archive.root if isinstance(archive, ConvergenceArchive) else archive - with sqlite3.connect(root / "source.db") as conn: - raw_session_count = int(conn.execute("SELECT COUNT(*) FROM raw_sessions").fetchone()[0]) - receipt_path = write_valid_rebuild_receipt( - root, - root.parent / f"{root.name}-test-schema-inference-receipt.json", - ) - receipt = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - promote=True, - raw_batch_size=max(1, raw_session_count), - schema_inference_receipt_path=receipt_path, - ) - ) - if receipt.status != "replayed" or not receipt.materialized: - raise AssertionError(f"retained-raw production reindex did not materialize a generation: {receipt!r}") - if receipt.selected_raw_count != receipt.raw_session_count or receipt.raw_session_count == 0: - raise AssertionError(f"retained-raw reindex did not select every source raw row: {receipt!r}") - if receipt.operation.get("recovery_state") != "promoted": - raise AssertionError(f"retained-raw reindex did not record promotion recovery state: {receipt!r}") - return receipt - - def initialize_active_archive(root: Path) -> None: """Create all archive tiers for a temporary property-test archive.""" from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -1005,7 +973,6 @@ def _stable_json(value: object, root: Path) -> str: "make_messages_fts_stale", "messages_fts_match_count", "raw_authority_facts", - "rebuild_retained_raw_index", "rich_convergence_pathology", "replay_convergence_archive", "rotated_session_order", diff --git a/tests/infra/corpus_program.py b/tests/infra/corpus_program.py index 592845b9fa..6d3a169570 100644 --- a/tests/infra/corpus_program.py +++ b/tests/infra/corpus_program.py @@ -2,9 +2,8 @@ The program layer owns only synthetic evidence and scheduling. It does not know how to write an archive. :class:`ProductionCorpusRuntime` delegates -those effects to the existing acquisition, parsing, convergence, hook, and -rebuild seams, which keeps this harness from becoming a second archive -engine. +those effects to the existing acquisition, parsing, convergence, and hook +seams, which keeps this harness from becoming a second archive engine. """ from __future__ import annotations @@ -22,7 +21,6 @@ from polylogue.core.json import JSONDocument, JSONValue, is_json_document if TYPE_CHECKING: - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt from polylogue.pipeline.services.parsing_models import ParseResult @@ -264,8 +262,6 @@ class OperationKind(StrEnum): CRASH = "Crash" RESTART = "Restart" CONVERGE = "Converge" - REBUILD = "Rebuild" - PROMOTE = "Promote" class CorpusRunner(Protocol): @@ -281,10 +277,6 @@ def restart(self) -> object: ... def converge(self) -> object: ... - def rebuild(self) -> object: ... - - def promote(self) -> object: ... - @dataclass(frozen=True, slots=True) class _Operation: @@ -507,29 +499,7 @@ def apply(self, state: CorpusState, runner: CorpusRunner | None) -> CorpusState: return state.mark(self.operation_id) -@dataclass(frozen=True, slots=True) -class Rebuild(_Operation): - kind: OperationKind = field(default=OperationKind.REBUILD, init=False) - - def apply(self, state: CorpusState, runner: CorpusRunner | None) -> CorpusState: - if runner is not None: - runner.rebuild() - return state.mark(self.operation_id) - - -@dataclass(frozen=True, slots=True) -class Promote(_Operation): - kind: OperationKind = field(default=OperationKind.PROMOTE, init=False) - - def apply(self, state: CorpusState, runner: CorpusRunner | None) -> CorpusState: - if runner is not None: - runner.promote() - return state.mark(self.operation_id) - - -CorpusOperation = ( - Acquire | Append | Replace | Duplicate | Fork | Attach | EmitHook | Crash | Restart | Converge | Rebuild | Promote -) +CorpusOperation = Acquire | Append | Replace | Duplicate | Fork | Attach | EmitHook | Crash | Restart | Converge @dataclass(frozen=True, slots=True) @@ -646,8 +616,6 @@ def _operation_from_dict(payload: Mapping[str, object]) -> CorpusOperation: OperationKind.CRASH: Crash, OperationKind.RESTART: Restart, OperationKind.CONVERGE: Converge, - OperationKind.REBUILD: Rebuild, - OperationKind.PROMOTE: Promote, } return cast(CorpusOperation, constructors[kind](operation_id)) @@ -737,8 +705,6 @@ def operation_strategy(*, artifact_ids: Sequence[str] = ("a", "b")) -> Any: st.builds(Crash, operation_id=st.text("op", min_size=2, max_size=8)), st.builds(Restart, operation_id=st.text("op", min_size=2, max_size=8)), st.builds(Converge, operation_id=st.text("op", min_size=2, max_size=8)), - st.builds(Rebuild, operation_id=st.text("op", min_size=2, max_size=8)), - st.builds(Promote, operation_id=st.text("op", min_size=2, max_size=8)), ) @@ -754,7 +720,6 @@ def _program(draw: Any) -> CorpusProgram: state = CorpusState() operations_by_id: dict[str, CorpusOperation] = {} crashed = False - rebuild_ready = False for step, operation_id in enumerate(schedule): operation: CorpusOperation @@ -772,9 +737,7 @@ def _program(draw: Any) -> CorpusProgram: operation = Restart(operation_id) else: artifact_ids = tuple(artifact.artifact_id for artifact in state.artifacts) - choices = ["append", "replace", "duplicate", "fork", "attach", "hook", "crash", "converge", "rebuild"] - if rebuild_ready: - choices.append("promote") + choices = ["append", "replace", "duplicate", "fork", "attach", "hook", "crash", "converge"] choice = draw(st.sampled_from(choices)) if choice == "append": operation = Append(operation_id, draw(st.sampled_from(artifact_ids)), draw(st.binary(max_size=32))) @@ -815,25 +778,11 @@ def _program(draw: Any) -> CorpusProgram: ) elif choice == "crash": operation = Crash(operation_id) - elif choice == "converge": - operation = Converge(operation_id) - elif choice == "rebuild": - operation = Rebuild(operation_id) - rebuild_ready = True else: - operation = Promote(operation_id) - rebuild_ready = False + operation = Converge(operation_id) operations_by_id[operation_id] = operation state = operation.apply(state, None) - if operation.kind is OperationKind.ACQUIRE or operation.kind in { - OperationKind.APPEND, - OperationKind.REPLACE, - OperationKind.DUPLICATE, - OperationKind.FORK, - OperationKind.ATTACH, - }: - rebuild_ready = False if operation.kind is OperationKind.CRASH: crashed = True elif operation.kind is OperationKind.RESTART: @@ -865,8 +814,6 @@ def __init__(self, archive_root: Path) -> None: self.source_root = self.archive_root / "corpus-program-sources" self._raw_ids: dict[str, tuple[str, ...]] = {} self._source_paths: dict[str, Path] = {} - self._rebuild_receipt: RebuildIndexReceipt | None = None - self._schema_inference_receipt_path: Path | None = None self._crashed = False self.last_results: list[object] = [] @@ -874,13 +821,6 @@ def _ensure_running(self) -> None: if self._crashed: raise CorpusRuntimeCrashedError("runtime is crashed; apply Restart before the next effect") - def bind_schema_inference_receipt(self, receipt_path: Path) -> None: - """Bind external rebuild authority without manufacturing it inside the harness.""" - resolved = receipt_path.resolve() - if not resolved.is_file(): - raise CorpusProgramError(f"schema-inference receipt does not exist: {resolved}") - self._schema_inference_receipt_path = resolved - def acquire(self, artifact: RawArtifact) -> object: self._ensure_running() from polylogue.config import Source @@ -984,51 +924,6 @@ async def parse() -> ParseResult: self.last_results.append(result) return result - def rebuild(self) -> RebuildIndexReceipt: - self._ensure_running() - from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source - - async def run() -> RebuildIndexReceipt: - return await rebuild_index_from_source( - RebuildIndexRequest( - archive_root=self.archive_root, - promote=False, - schema_inference_receipt_path=self._schema_inference_receipt_path, - ) - ) - - result = asyncio.run(run()) - self._rebuild_receipt = result - self.last_results.append(result) - return result - - def promote(self) -> RebuildIndexReceipt: - self._ensure_running() - if self._rebuild_receipt is None: - raise CorpusProgramError("Promote requires a preceding Rebuild") - transaction = self._rebuild_receipt.transaction - if not isinstance(transaction, dict): - raise CorpusProgramError("Rebuild did not return a durable rebuild transaction") - operation_id = transaction.get("operation_id") - if not isinstance(operation_id, str): - raise CorpusProgramError("Rebuild transaction has no operation id") - from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source - - async def run() -> RebuildIndexReceipt: - return await rebuild_index_from_source( - RebuildIndexRequest( - archive_root=self.archive_root, - operation_id=operation_id, - promote=True, - schema_inference_receipt_path=self._schema_inference_receipt_path, - ) - ) - - result = asyncio.run(run()) - self._rebuild_receipt = result - self.last_results.append(result) - return result - def _attachment_wire_payload(artifact: RawArtifact) -> bytes: """Encode corpus attachments through the existing browser-capture route.""" @@ -1118,9 +1013,7 @@ def _normalized_hook_envelope(hook: HookArtifact, provider: Any) -> dict[str, ob "HookArtifact", "OperationKind", "ProductionCorpusRuntime", - "Promote", "RawArtifact", - "Rebuild", "Replace", "Restart", "corpus_program_strategy", diff --git a/tests/infra/excluded_cursor_live_proof.py b/tests/infra/excluded_cursor_live_proof.py index 2727bbd080..56d16ed415 100644 --- a/tests/infra/excluded_cursor_live_proof.py +++ b/tests/infra/excluded_cursor_live_proof.py @@ -26,7 +26,35 @@ from polylogue.sources.live.watcher import LiveWatcher, WatchSource from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.reindex_campaign import _codex_records, _write_jsonl + + +def _codex_records( + session_id: str, texts: tuple[str, ...], *, parent: str | None = None +) -> tuple[dict[str, object], ...]: + meta: dict[str, object] = {"id": session_id, "timestamp": "2026-08-05T00:00:00Z"} + if parent is not None: + meta["forked_from_id"] = parent + records: list[dict[str, object]] = [{"type": "session_meta", "payload": meta}] + for position, text in enumerate(texts): + records.append( + { + "type": "response_item", + "payload": { + "type": "message", + "id": f"{session_id}-m{position}", + "role": "user" if position % 2 == 0 else "assistant", + "content": [{"type": "input_text", "text": text}], + }, + } + ) + return tuple(records) + + +def _write_jsonl(path: Path, records: tuple[dict[str, object], ...]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("".join(json.dumps(record, sort_keys=True) + "\n" for record in records), encoding="utf-8") + return path + RECEIPT_SCHEMA = "polylogue.excluded-cursor-live-proof.v2" FIXTURE_VERSION = "candidate-codex-live-compatible-2026-08-06" diff --git a/tests/infra/reindex_campaign.py b/tests/infra/reindex_campaign.py deleted file mode 100644 index 550f8f56af..0000000000 --- a/tests/infra/reindex_campaign.py +++ /dev/null @@ -1,575 +0,0 @@ -"""Real-pipeline corpus and manifest for the reindex campaign tranche. - -The campaign deliberately starts with provider-shaped wire artifacts. The -archive is populated by ``parse_sources_archive`` and converged by the same -daemon stages used by the product. The parser-failure fixture is then written -through ``ArchiveStore`` and finalized through its typed parse-state route. -""" - -from __future__ import annotations - -import asyncio -import json -import sqlite3 -from dataclasses import dataclass -from pathlib import Path -from unittest.mock import patch - -from polylogue.config import Source -from polylogue.core.enums import Provider -from polylogue.daemon.convergence import DaemonConverger -from polylogue.daemon.convergence_stages import make_fts_stage, make_insights_stage -from polylogue.pipeline.services.archive_ingest import parse_sources_archive -from polylogue.scenarios import CorpusSpec -from polylogue.schemas.synthetic import SyntheticCorpus -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence -from polylogue.storage.blob_gc import unlink_unreferenced_blob_hashes_under_exclusion -from polylogue.storage.blob_integrity import scan_blob_integrity -from polylogue.storage.blob_publication import abandon_blob_publication_receipts, inspect_blob_publication_receipts -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from polylogue.storage.sqlite.archive_tiers.revision_governance import record_current_parser_source_census -from tests.infra.source_builders import SyntheticAntigravityLanguageServerClient, provider_source_package -from tests.infra.whale_fixtures import WHALE_FIXTURE_DIMENSIONS - -REINDEX_CAMPAIGN_REQUIRED_ORIGINS = frozenset( - { - "aistudio-drive", - "antigravity-session", - "chatgpt-export", - "claude-ai-export", - "claude-code-session", - "codex-session", - "gemini-cli-session", - "grok-export", - "hermes-session", - } -) - -REINDEX_CAMPAIGN_MINIMUMS: dict[str, int] = { - "lineage_edges": 1, - "attachment_refs": 1, - "attachment_blob_refs": 1, - "attachment_blob_bytes": 1, - "structured_tool_failures": 1, - "materialized_insight_sessions": 1, - "fts_documents": 1, - "parser_failure_residuals": 1, - "duplicate_raw_rows": 1, - "restart_debt_sessions": 1, -} - - -@dataclass(frozen=True, slots=True) -class ReindexCampaignManifest: - """Stable campaign evidence, including positive class denominators.""" - - specs: tuple[CorpusSpec, ...] - session_ids: tuple[str, ...] - lineage_session_ids: tuple[str, ...] - attachment_session_ids: tuple[str, ...] - restart_session_ids: tuple[str, ...] - parser_failure_raw_ids: tuple[str, ...] - duplicate_raw_ids: tuple[str, ...] - fts_queries: tuple[str, ...] - origin_session_counts: tuple[tuple[str, int], ...] - denominators: tuple[tuple[str, int], ...] - fixture_dimensions: tuple[tuple[str, int | str], ...] - - def denominator(self, name: str) -> int: - try: - return dict(self.denominators)[name] - except KeyError as exc: - raise AssertionError(f"campaign manifest has no denominator {name!r}") from exc - - def assert_positive(self) -> None: - for name, minimum in REINDEX_CAMPAIGN_MINIMUMS.items(): - actual = self.denominator(name) - if actual < minimum: - raise AssertionError(f"campaign denominator {name!r} is {actual}, expected at least {minimum}") - actual_origins = {origin for origin, count in self.origin_session_counts if count > 0} - if actual_origins != REINDEX_CAMPAIGN_REQUIRED_ORIGINS: - missing = sorted(REINDEX_CAMPAIGN_REQUIRED_ORIGINS - actual_origins) - unexpected = sorted(actual_origins - REINDEX_CAMPAIGN_REQUIRED_ORIGINS) - raise AssertionError(f"campaign origin coverage mismatch: missing={missing}, unexpected={unexpected}") - - -@dataclass(frozen=True, slots=True) -class ReindexCampaignCorpus: - root: Path - manifest: ReindexCampaignManifest - - -def reindex_campaign_corpus_specs() -> tuple[CorpusSpec, ...]: - """Return authored provider specs used by the campaign corpus.""" - - return ( - CorpusSpec.for_provider( - "chatgpt", - count=2, - messages_min=4, - messages_max=4, - seed=800, - style="tool-heavy", - session_native_ids=("campaign-chatgpt-a", "campaign-chatgpt-b"), - origin="test.reindex-campaign", - tags=("reindex", "campaign", "chatgpt-tool-results"), - ), - CorpusSpec.for_provider( - "claude-ai", - count=2, - messages_min=4, - messages_max=4, - seed=801, - style="tool-heavy", - session_native_ids=("campaign-claude-ai-a", "campaign-claude-ai-b"), - origin="test.reindex-campaign", - tags=("reindex", "campaign", "claude-ai-tool-results"), - ), - CorpusSpec.for_provider( - "codex", - count=3, - messages_min=4, - messages_max=4, - seed=801, - style="tool-heavy", - session_native_ids=("campaign-codex-a", "campaign-codex-b", "campaign-codex-c"), - origin="test.reindex-campaign", - tags=("reindex", "campaign", "tool-results"), - ), - CorpusSpec.for_provider( - "claude-code", - count=4, - messages_min=4, - messages_max=4, - seed=802, - style="demo-tool-heavy", - session_native_ids=( - "campaign-claude-failure-a", - "campaign-claude-failure-b", - "campaign-claude-stable-a", - "campaign-claude-stable-b", - ), - origin="test.reindex-campaign", - tags=("reindex", "campaign", "structured-failures"), - ), - CorpusSpec.for_provider( - "gemini", - count=1, - messages_min=3, - messages_max=3, - seed=803, - style="demo-attachments", - session_native_ids=("campaign-attachment",), - origin="test.reindex-campaign", - tags=("reindex", "campaign", "attachments"), - ), - ) - - -def _codex_records( - session_id: str, texts: tuple[str, ...], *, parent: str | None = None -) -> tuple[dict[str, object], ...]: - meta: dict[str, object] = {"id": session_id, "timestamp": "2026-08-05T00:00:00Z"} - if parent is not None: - meta["forked_from_id"] = parent - records: list[dict[str, object]] = [{"type": "session_meta", "payload": meta}] - for position, text in enumerate(texts): - records.append( - { - "type": "response_item", - "payload": { - "type": "message", - "id": f"{session_id}-m{position}", - "role": "user" if position % 2 == 0 else "assistant", - "content": [{"type": "input_text", "text": text}], - }, - } - ) - return tuple(records) - - -def _write_jsonl(path: Path, records: tuple[dict[str, object], ...]) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("".join(json.dumps(record, sort_keys=True) + "\n" for record in records), encoding="utf-8") - return path - - -def _write_json(path: Path, payload: object) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8") - return path - - -def _gemini_cli_payload() -> dict[str, object]: - return { - "sessionId": "campaign-gemini-cli", - "projectHash": "campaign-project", - "startTime": "2026-08-05T00:00:00.000Z", - "lastUpdated": "2026-08-05T00:01:00.000Z", - "kind": "chat", - "summary": "Campaign Gemini CLI witness", - "messages": [ - {"id": "u1", "timestamp": "2026-08-05T00:00:01.000Z", "type": "user", "content": ["inspect archive"]}, - { - "id": "a1", - "timestamp": "2026-08-05T00:00:02.000Z", - "type": "gemini", - "content": "I will inspect the archive.", - "thoughts": [{"text": "Need a native checkpoint witness."}], - "toolCalls": [{"id": "read-1", "name": "read_file", "arguments": {"path": "source.db"}}], - }, - ], - } - - -def _hermes_payload() -> dict[str, object]: - return { - "session_id": "campaign-hermes", - "model": "hermes-campaign", - "platform": "linux", - "session_start": "2026-08-05T00:00:00.000000", - "last_updated": "2026-08-05T00:01:00.000000", - "messages": [ - {"role": "user", "content": "run integrity checks"}, - { - "role": "assistant", - "content": "Running integrity checks.", - "reasoning_content": "Use the native local-agent tool shape.", - "tool_calls": [{"id": "integrity-1", "function": {"name": "shell", "arguments": '{"cmd": "check"}'}}], - }, - {"role": "tool", "tool_call_id": "integrity-1", "content": "0 anomalies"}, - ], - } - - -def _grok_payload() -> dict[str, object]: - return { - "conversation": { - "title": "Campaign Grok witness", - "create_time": {"$date": {"$numberLong": "1754352000000"}}, - }, - "responses": [ - { - "response": { - "sender": "human", - "message": "Summarize the archive state.", - "create_time": {"$date": {"$numberLong": "1754352001000"}}, - } - }, - { - "response": { - "sender": "assistant", - "message": "The archive is being reindexed.", - "create_time": {"$date": {"$numberLong": "1754352002000"}}, - } - }, - ], - } - - -def _grok_export_payload() -> dict[str, object]: - return {"conversations": [_grok_payload()]} - - -def _campaign_session_ids(root: Path) -> tuple[str, ...]: - with sqlite3.connect(root / "index.db") as conn: - return tuple(str(row[0]) for row in conn.execute("SELECT session_id FROM sessions ORDER BY session_id")) - - -def _campaign_manifest( - root: Path, - *, - specs: tuple[CorpusSpec, ...], - parser_failure_raw_ids: tuple[str, ...], - duplicate_raw_ids: tuple[str, ...], -) -> ReindexCampaignManifest: - with sqlite3.connect(root / "index.db") as index_conn: - session_ids = _campaign_session_ids(root) - lineage_session_ids = tuple( - str(row[0]) - for row in index_conn.execute( - "SELECT DISTINCT src_session_id FROM session_links WHERE link_type = 'branch' ORDER BY src_session_id" - ) - ) - attachment_session_ids = tuple( - str(row[0]) - for row in index_conn.execute( - "SELECT DISTINCT session_id FROM attachment_refs WHERE session_id IS NOT NULL ORDER BY session_id" - ) - ) - structured_tool_failures = int( - index_conn.execute( - """ - SELECT COUNT(*) FROM blocks - WHERE block_type = 'tool_result' - AND (tool_result_is_error = 1 OR tool_result_exit_code != 0) - """ - ).fetchone()[0] - ) - materialized_insight_sessions = int( - index_conn.execute("SELECT COUNT(DISTINCT session_id) FROM insight_materialization").fetchone()[0] - ) - fts_documents = int(index_conn.execute("SELECT COUNT(*) FROM messages_fts").fetchone()[0]) - attachment_refs = int(index_conn.execute("SELECT COUNT(*) FROM attachment_refs").fetchone()[0]) - attachment_blob_bytes = int( - index_conn.execute( - "SELECT COALESCE(SUM(byte_count), 0) FROM attachments WHERE blob_hash IS NOT NULL" - ).fetchone()[0] - ) - restart_session_ids = tuple( - str(row[0]) - for row in index_conn.execute( - "SELECT session_id FROM sessions WHERE origin = 'claude-code-session' ORDER BY session_id LIMIT 1" - ) - ) - origin_session_counts = tuple( - (str(origin), int(count)) - for origin, count in index_conn.execute( - "SELECT origin, COUNT(*) FROM sessions GROUP BY origin ORDER BY origin" - ) - ) - with sqlite3.connect(root / "source.db") as source_conn: - attachment_blob_refs = int( - source_conn.execute("SELECT COUNT(*) FROM blob_refs WHERE ref_type = 'attachment'").fetchone()[0] - ) - duplicate_rows = int( - source_conn.execute( - """ - SELECT COUNT(*) FROM raw_sessions - WHERE blob_hash IN ( - SELECT blob_hash FROM raw_sessions GROUP BY blob_hash HAVING COUNT(*) > 1 - ) - """ - ).fetchone()[0] - ) - parser_failure_residuals = int( - source_conn.execute( - "SELECT COUNT(*) FROM raw_sessions WHERE parsed_at_ms IS NULL AND parse_error IS NOT NULL" - ).fetchone()[0] - ) - denominators = ( - ("lineage_edges", len(lineage_session_ids)), - ("attachment_refs", attachment_refs), - ("attachment_blob_refs", attachment_blob_refs), - ("attachment_blob_bytes", attachment_blob_bytes), - ("structured_tool_failures", structured_tool_failures), - ("materialized_insight_sessions", materialized_insight_sessions), - ("fts_documents", fts_documents), - ("parser_failure_residuals", parser_failure_residuals), - ("duplicate_raw_rows", duplicate_rows), - ("restart_debt_sessions", len(restart_session_ids)), - ) - manifest = ReindexCampaignManifest( - specs=specs, - session_ids=session_ids, - lineage_session_ids=lineage_session_ids, - attachment_session_ids=attachment_session_ids, - restart_session_ids=restart_session_ids, - parser_failure_raw_ids=parser_failure_raw_ids, - duplicate_raw_ids=duplicate_raw_ids, - fts_queries=("generated", "fixture", "failed"), - origin_session_counts=origin_session_counts, - denominators=denominators, - fixture_dimensions=WHALE_FIXTURE_DIMENSIONS.manifest_dimensions(), - ) - manifest.assert_positive() - if parser_failure_residuals < len(parser_failure_raw_ids): - raise AssertionError("campaign parser-failure manifest exceeds source residuals") - if duplicate_rows < len(duplicate_raw_ids): - raise AssertionError("campaign duplicate manifest exceeds duplicate source rows") - return manifest - - -def build_reindex_campaign_corpus(root: Path) -> ReindexCampaignCorpus: - """Build and converge the campaign corpus through production routes.""" - - root = Path(root) - initialize_active_archive_root(root) - specs = reindex_campaign_corpus_specs() - wire_root = root / "wire" - sources: list[Source] = [] - first_payload: bytes | None = None - for index, spec in enumerate(specs): - written = SyntheticCorpus.write_spec_artifacts(spec, wire_root / spec.provider, prefix=f"campaign-{index:02d}") - if spec.provider == "codex": - first_payload = written.files[0].read_bytes() - source_paths = (written.files[0].parent.parent,) if spec.provider == "antigravity" else written.files - sources.extend( - provider_source_package(spec.provider, written.files, source_paths=source_paths).admitted_sources() - ) - - native_dir = wire_root / "native" - sources.extend( - ( - Source(name="gemini-cli", path=_write_json(native_dir / "gemini-cli.json", _gemini_cli_payload())), - Source(name="hermes", path=_write_json(native_dir / "hermes.json", _hermes_payload())), - Source(name="grok", path=_write_json(native_dir / "grok.json", _grok_export_payload())), - ) - ) - antigravity_path = native_dir / "antigravity" / "conversations" / "campaign-antigravity.pb" - antigravity_path.parent.mkdir(parents=True, exist_ok=True) - antigravity_path.write_bytes(b"campaign-antigravity-protobuf") - sources.append(Source(name="antigravity", path=antigravity_path.parent.parent)) - - lineage_dir = wire_root / "lineage" - sources.append( - Source( - name="codex", - path=_write_jsonl( - lineage_dir / "campaign-lineage-parent.jsonl", - _codex_records("campaign-lineage-parent", ("shared campaign prefix", "parent tail")), - ), - ) - ) - sources.append( - Source( - name="codex", - path=_write_jsonl( - lineage_dir / "campaign-lineage-child.jsonl", - _codex_records( - "campaign-lineage-child", - ("child tail",), - parent="campaign-lineage-parent", - ), - ), - ) - ) - assert first_payload is not None - with patch( - "polylogue.sources.parsers.antigravity.AntigravityLanguageServerClient", - SyntheticAntigravityLanguageServerClient, - ): - parse_result = asyncio.run(parse_sources_archive(root, sources, parse_workers=1)) - if parse_result.parse_failures != 0: - raise AssertionError(f"campaign provider ingest unexpectedly failed: {parse_result.parse_failures}") - - with sqlite3.connect(root / "source.db") as source_conn: - restart_native_id = source_conn.execute( - """ - SELECT m.provider_session_id - FROM raw_session_memberships AS m - JOIN raw_sessions AS r USING (raw_id) - WHERE r.origin = 'claude-code-session' - ORDER BY m.provider_session_id - LIMIT 1 - """ - ).fetchone() - restart_native_id = str(restart_native_id[0]) - - duplicate_paths = tuple(wire_root / "duplicates" / f"campaign-duplicate-{suffix}.jsonl" for suffix in ("a", "b")) - duplicate_sources = [Source(name="codex", path=path) for path in duplicate_paths] - for path in duplicate_paths: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(first_payload) - duplicate_parse_result = asyncio.run(parse_sources_archive(root, duplicate_sources, parse_workers=1)) - if duplicate_parse_result.parse_failures != 0: - raise AssertionError(f"campaign duplicate ingest unexpectedly failed: {duplicate_parse_result.parse_failures}") - with sqlite3.connect(root / "source.db") as source_conn: - duplicate_raw_ids = tuple( - str(row[0]) - for row in source_conn.execute( - "SELECT raw_id FROM raw_sessions WHERE source_path IN (?, ?) ORDER BY source_path", - tuple(str(path) for path in duplicate_paths), - ) - ) - if len(duplicate_raw_ids) != len(duplicate_paths): - raise AssertionError("campaign duplicate ingest did not retain both raw acquisitions") - - # Duplicate publication attempts can leave bytes that were never admitted - # to a raw-session row. Resolve that deliberate fixture shape through the - # same scanner and locked GC deletion seam used by maintenance, so the - # campaign corpus has no unexplained physical bytes before it becomes a - # rebuild source snapshot. - blob_report = scan_blob_integrity( - root / "source.db", - store=BlobStore(root / "blob"), - full=True, - configured_root=root, - ) - orphan_finding = next((finding for finding in blob_report.findings if finding.kind == "orphan_blobs"), None) - if orphan_finding is not None: - orphan_hashes = set(orphan_finding.sample) - receipts = inspect_blob_publication_receipts(root / "source.db", root / "blob", index_db_path=root / "index.db") - abandoned = abandon_blob_publication_receipts( - root / "source.db", - root / "blob", - [receipt.publication_id for receipt in receipts if receipt.blob_hash in orphan_hashes], - confirmed=True, - index_db_path=root / "index.db", - ) - deleted, _deleted_bytes, blockers = unlink_unreferenced_blob_hashes_under_exclusion( - root / "source.db", - root / "index.db", - root / "blob", - orphan_hashes, - ) - if blockers or abandoned.abandoned != orphan_finding.count or deleted != orphan_finding.count: - raise AssertionError( - "campaign fixture orphan disposition failed: " - f"found={orphan_finding.count} abandoned={abandoned.abandoned} deleted={deleted} blockers={blockers}" - ) - - with ArchiveStore.open_existing(root, read_only=False) as archive: - parser_failure_raw_id = archive.write_raw_payload( - provider=Provider.CLAUDE_CODE, - payload=first_payload, - source_path="campaign-parser-failure.jsonl", - native_id=restart_native_id, - acquired_at_ms=3, - ) - from polylogue.core.raw_failure_evidence import RawFailureEvidenceKind - - archive.record_raw_failure_evidence( - parser_failure_raw_id, - provider=Provider.CLAUDE_CODE, - source_path="campaign-parser-failure.jsonl", - source_index=0, - acquired_at_ms=3, - kind=RawFailureEvidenceKind.TERMINAL_CORRUPT_INPUT, - ) - archive.mark_raw_parse_failed( - parser_failure_raw_id, - provider=Provider.CLAUDE_CODE, - error=ValueError("campaign parser failure"), - preserve_existing_failure_evidence=True, - ) - with archive._ensure_source_conn(): - record_current_parser_source_census(archive._ensure_source_conn(), parser_failure_raw_id) - - # Phase-2 source freeze requires byte authority for single-document raws - # and membership authority for grouped/provider bundles. Exercise the - # complete production remediation route so the fixture cannot substitute - # one authority model for the other or edit durable columns directly. - with patch( - "polylogue.sources.parsers.antigravity.AntigravityLanguageServerClient", - SyntheticAntigravityLanguageServerClient, - ): - backfill_historical_revision_evidence(root, ingest_workers=1) - - session_ids = _campaign_session_ids(root) - states, _timings = DaemonConverger( - (make_fts_stage(root / "index.db"), make_insights_stage(root / "index.db")) - ).converge_sessions(session_ids) - not_converged = {session_id: state.last_error for session_id, state in states.items() if not state.converged} - if not_converged: - raise AssertionError(f"campaign corpus did not converge: {not_converged}") - - manifest = _campaign_manifest( - root, - specs=specs, - parser_failure_raw_ids=(parser_failure_raw_id,), - duplicate_raw_ids=duplicate_raw_ids, - ) - return ReindexCampaignCorpus(root=root, manifest=manifest) - - -__all__ = [ - "REINDEX_CAMPAIGN_MINIMUMS", - "REINDEX_CAMPAIGN_REQUIRED_ORIGINS", - "ReindexCampaignCorpus", - "ReindexCampaignManifest", - "build_reindex_campaign_corpus", - "reindex_campaign_corpus_specs", -] diff --git a/tests/infra/storage_records.py b/tests/infra/storage_records.py index 705ac32670..4e28044ea0 100644 --- a/tests/infra/storage_records.py +++ b/tests/infra/storage_records.py @@ -1451,20 +1451,12 @@ def mark_as_phantom_debris(self, native_id: str, *, provider: str = "test") -> s """Attach an ``agent-*.meta.json``-shaped phantom raw artifact to an already-created session and link it via ``sessions.raw_id``. - ``count_empty_sessions_sync``/``repair_empty_sessions`` - (``polylogue/storage/repair.py``) only ever count a message-less (or - all-zero-word) session as debris when its raw artifact *positively - fails* the current record-shape classifier - (``_raw_artifact_positively_fails_classification``) -- a session - created via :meth:`create_session` with no raw content at all has - ``raw_id IS NULL``, which the classifier treats as "no evidence - either way" and therefore always retains (never counted as debt). - This seeds the same phantom shape - ``tests/unit/storage/test_empty_session_repair_provenance.py``'s - ``_seed`` helper uses (an ``agent-*.meta.json`` sidecar path, a - genuinely-debris shape the classifier positively refuses), so a - caller that wants an "empty" session to actually register as - maintenance debt must call this after :meth:`create_session`. + A session created via :meth:`create_session` with no raw content at + all has ``raw_id IS NULL``; this attaches the phantom + ``agent-*.meta.json`` sidecar shape a record-shape classifier + positively refuses, so a caller that wants an "empty" session to + carry positively-failing raw evidence must call this after + :meth:`create_session`. Resolves the blob store from ``self.db_path``'s own parent directory (this factory's archive root), never the ambient diff --git a/tests/infra/test_corpus_program.py b/tests/infra/test_corpus_program.py index c3c2e389a1..57104c45bc 100644 --- a/tests/infra/test_corpus_program.py +++ b/tests/infra/test_corpus_program.py @@ -9,9 +9,7 @@ import pytest from hypothesis import given -from polylogue.sources.revision_backfill import backfill_historical_revision_evidence from polylogue.storage.blob_store import BlobStore -from polylogue.storage.index_generation import IndexGenerationStore from tests.infra.corpus_program import ( Acquire, Append, @@ -26,15 +24,12 @@ Fork, HookArtifact, ProductionCorpusRuntime, - Promote, RawArtifact, - Rebuild, Replace, Restart, corpus_program_schedule_strategy, corpus_program_strategy, ) -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt class RecordingRunner: @@ -58,12 +53,6 @@ def restart(self) -> None: def converge(self) -> None: self.calls.append("converge") - def rebuild(self) -> None: - self.calls.append("rebuild") - - def promote(self) -> None: - self.calls.append("promote") - def _artifact(artifact_id: str, payload: bytes = b"payload") -> RawArtifact: return RawArtifact( @@ -97,8 +86,6 @@ def test_program_serialization_is_canonical_and_round_trips_all_operation_shapes Crash("crash"), Restart("restart"), Converge("converge"), - Rebuild("rebuild"), - Promote("promote"), ) program = CorpusProgram(operations, schedule=tuple(operation.operation_id for operation in operations)) @@ -282,84 +269,6 @@ def test_production_route_persists_canonical_hook_envelope(workspace_env: dict[s } -def _freeze_runtime_source( - runtime: ProductionCorpusRuntime, - receipt_path: Path, - *, - expected_raws: int = 1, -) -> None: - runtime.converge() - backfill = backfill_historical_revision_evidence(runtime.archive_root) - assert backfill.scanned == expected_raws - assert backfill.classified_full == expected_raws - assert backfill.replayed_logical_sources + backfill.adoption_deferred == 1 - runtime.bind_schema_inference_receipt(write_valid_rebuild_receipt(runtime.archive_root, receipt_path)) - - -def test_production_route_promotes_the_owned_candidate_with_terminal_transaction( - workspace_env: dict[str, Path], -) -> None: - fixture_path = Path(__file__).parents[1] / "data" / "codex_event_stream" / "text_only_stream.jsonl" - runtime = ProductionCorpusRuntime(workspace_env["archive_root"]) - runtime.acquire(_artifact("session", fixture_path.read_bytes())) - _freeze_runtime_source( - runtime, - workspace_env["archive_root"].parent / "corpus-program-schema-inference-receipt.json", - ) - store = IndexGenerationStore.for_archive_root(runtime.archive_root) - active_before = store.active_pointer.resolve(strict=True) - - built = runtime.rebuild() - - assert built.status == "replayed" - assert built.generation["state"] == "inactive" - assert built.transaction is not None - assert built.transaction["status"] == "ready" - operation_id = built.transaction["operation_id"] - assert store.active_pointer.resolve(strict=True) == active_before - - promoted = runtime.promote() - - assert promoted.status == "replayed" - assert promoted.generation["state"] == "active" - assert promoted.transaction is not None - assert promoted.transaction["operation_id"] == operation_id - assert promoted.transaction["status"] == "promoted" - assert store.active_pointer.resolve(strict=True) == Path(str(promoted.generation["index_path"])).resolve() - - -def test_production_route_refuses_promotion_after_source_drift( - workspace_env: dict[str, Path], -) -> None: - fixture_path = Path(__file__).parents[1] / "data" / "codex_event_stream" / "text_only_stream.jsonl" - runtime = ProductionCorpusRuntime(workspace_env["archive_root"]) - artifact = _artifact("session", fixture_path.read_bytes()) - runtime.acquire(artifact) - _freeze_runtime_source( - runtime, - workspace_env["archive_root"].parent / "corpus-program-source-drift-receipt.json", - ) - store = IndexGenerationStore.for_archive_root(runtime.archive_root) - active_before = store.active_pointer.resolve(strict=True) - - built = runtime.rebuild() - assert built.transaction is not None - operation_id = str(built.transaction["operation_id"]) - runtime.acquire(artifact.with_payload(artifact.payload + b"\n")) - _freeze_runtime_source( - runtime, - workspace_env["archive_root"].parent / "corpus-program-post-drift-receipt.json", - expected_raws=2, - ) - - with pytest.raises(RuntimeError, match="source evidence changed"): - runtime.promote() - - transaction = store.load_transaction(operation_id) - assert transaction.status == "stale" - assert store.active_pointer.resolve(strict=True) == active_before - - def test_emit_hook_refuses_non_object_payload_with_named_reason(tmp_path: Path) -> None: runtime = ProductionCorpusRuntime(tmp_path / "archive") with pytest.raises(CorpusProgramError, match="EmitHook refused: payload is not a JSON object"): diff --git a/tests/property/test_inferred_corpus_loop.py b/tests/property/test_inferred_corpus_loop.py index 0d88da1ec8..9d343337cf 100644 --- a/tests/property/test_inferred_corpus_loop.py +++ b/tests/property/test_inferred_corpus_loop.py @@ -35,7 +35,7 @@ from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from tests.infra.archive_canonical_snapshot import archive_snapshot, assert_archives_equivalent -from tests.infra.convergence_harness import rebuild_retained_raw_index, set_debt_retry_at +from tests.infra.convergence_harness import set_debt_retry_at from tests.infra.inferred_corpus import ( assert_inferred_corpus_convergence_handoff_complete, build_inferred_corpus_convergence_handoff, @@ -147,18 +147,6 @@ def _ingest_and_converge_sources( return session_ids -def _converge_existing_archive(archive_root: Path) -> None: - """Run post-reindex convergence over the promoted generation.""" - with sqlite3.connect(archive_root / "index.db") as conn: - session_ids = tuple(str(row[0]) for row in conn.execute("SELECT session_id FROM sessions ORDER BY session_id")) - states, _timings = DaemonConverger( - (make_fts_stage(archive_root / "index.db"), make_insights_stage(archive_root / "index.db")) - ).converge_sessions(session_ids) - assert states and all(state.converged and state.last_error is None for state in states.values()) - with sqlite3.connect(archive_root / "index.db") as conn: - record_fts_invariant_snapshot_sync(conn, fts_invariant_snapshot_sync(conn)) - - def _lineage_material() -> tuple[bytes, bytes, str, str]: spec, selection = _inferred_selection() parent_spec = replace( @@ -409,28 +397,6 @@ def test_every_supported_inferred_element_reaches_convergence_and_red_twin( assert check.status is OutcomeStatus.ERROR -@pytest.mark.frozen_clock_modules("polylogue.storage.sqlite.archive_tiers.revision_governance") -def test_inferred_selection_retained_raw_reindex_matches_canonical_snapshot( - tmp_path: Path, - frozen_clock: object, -) -> None: - spec, selection = _inferred_selection() - source_root = tmp_path / "retained-source" - written = SyntheticCorpus.write_selection_artifacts(selection, spec, source_root, prefix="retained") - archive_root = tmp_path / "archive" - session_ids = _ingest_and_converge_sources( - archive_root, - (Source(name=spec.provider, path=written.files[0]),), - ) - before = archive_snapshot(archive_root, session_ids=session_ids) - - receipt = rebuild_retained_raw_index(archive_root) - _converge_existing_archive(archive_root) - - assert receipt.raw_session_count == receipt.selected_raw_count > 0 - assert archive_snapshot(archive_root, session_ids=session_ids) == before - - @pytest.mark.uses_real_clock("fresh-process debt recovery crosses a subprocess wall-clock retry boundary") def test_inferred_selection_debt_recovers_in_a_fresh_process(tmp_path: Path) -> None: spec, selection = _inferred_selection() @@ -502,12 +468,6 @@ def test_inferred_lineage_reindex_preserves_composition_and_detects_tail_mutatio assert len(composed.messages) > int(parent[1]) assert any(message.message_id.startswith(parent_id + ":") for message in composed.messages) - rebuilt_root = tmp_path / "rebuilt" - _build_lineage_archive(rebuilt_root, parent_raw, child_raw) - rebuild_retained_raw_index(rebuilt_root) - _converge_existing_archive(rebuilt_root) - assert archive_snapshot(rebuilt_root) == archive_snapshot(canonical_root) - mutated_records = [json.loads(line) for line in child_raw.decode().splitlines() if line] mutated_message = mutated_records[-1].get("payload", mutated_records[-1]) assert isinstance(mutated_message, dict) diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 6b6565d402..54e56ad871 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -1,8 +1,6 @@ from __future__ import annotations -import asyncio import hashlib -import itertools import json import os import shutil @@ -16,32 +14,25 @@ import pytest from click.testing import CliRunner -from polylogue.archive.revision_authority import RawRevisionAuthority, RawRevisionEnvelope, RawRevisionKind from polylogue.cli.click_app import cli from polylogue.cli.command_inventory import iter_command_paths -from polylogue.cli.commands.maintenance import _rebuild_index as maintenance_rebuild_index from polylogue.cli.commands.maintenance._migrate_tier import ( MigrateTierErrorPayload, MigrateTierResultPayload, MigrateTierSuccessPayload, ) -from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import json_document from polylogue.daemon.backup import backup_archive -from polylogue.maintenance.models import MaintenanceCategory from polylogue.maintenance.raw_authority_recovery import ( RawAuthorityRecoveryOperation, inspect_raw_authority_recovery, write_recovery_plan, ) -from polylogue.maintenance.replay import rebuild_index_from_source, state_path_for -from polylogue.sources.revision_backfill import census_historical_revision_evidence from polylogue.storage.blob_gc import read_gc_history from polylogue.storage.blob_publication import ArchiveBlobPublisher from polylogue.storage.blob_store import BlobStore from polylogue.storage.raw_authority import RawReplayPlan, record_raw_authority_census -from polylogue.storage.repair import RepairResult from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.archive import ArchiveSessionSearchHit, ArchiveSessionSummary, ArchiveStore from polylogue.storage.sqlite.archive_tiers.archive_init import ( @@ -50,71 +41,14 @@ ) from polylogue.storage.sqlite.archive_tiers.archive_plan import ArchiveInitAction, ArchiveInitPlan from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS, initialize_archive_tier -from polylogue.storage.sqlite.archive_tiers.source import SOURCE_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.source_write import write_source_raw_session_blob_ref from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.user import USER_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.user_write import AssertionKind, upsert_assertion -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt _ARCHIVE_TIERS = tuple(spec.filename for spec in ARCHIVE_TIER_SPECS.values()) -@pytest.mark.parametrize("blocked", [False, True]) -def test_replay_cli_explicit_malformed_cursor_is_typed_and_state_stable( - cli_workspace: dict[str, Path], cli_runner: CliRunner, blocked: bool -) -> None: - """The public CLI and executor reject malformed cursors identically. - - Cursor validation must not depend on whether the offline daemon blocker is - active, and must not replace an existing resumable checkpoint. - """ - root = cli_workspace["archive_root"] - operation_id = f"cli-invalid-cursor-{blocked}" - config = Config(archive_root=root, render_root=cli_workspace["render_root"], sources=[]) - path = state_path_for(config, operation_id) - path.parent.mkdir(parents=True, exist_ok=True) - original = '{"operation_id":"cli-invalid-cursor","cursor":"target:0"}' - path.write_text(original) - blocker = RepairResult( - name="session_insights", - category=MaintenanceCategory.DERIVED_REPAIR, - destructive=False, - repaired_count=0, - success=False, - detail="daemon is running", - ) - - with patch( - "polylogue.maintenance.replay.offline_maintenance_blockers", - return_value=[blocker] if blocked else [], - ) as blocker_check: - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "run", - "--target", - "session_insights", - "--operation-id", - operation_id, - "--resume", - "", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 1, result.output - payload = json.loads(result.stdout) - assert payload["failure_samples"]["samples"][0]["kind"] == "InvalidReplayCursor" - assert path.read_text() == original - blocker_check.assert_not_called() - - def test_raw_authority_census_cli_resolves_receipt_handle( cli_workspace: dict[str, Path], cli_runner: CliRunner, @@ -638,26 +572,6 @@ def _refresh_fresh_bootstrap_marker(archive_root: Path) -> None: _record_fresh_durable_bootstrap(archive_root) -def _freeze_rebuild_fixture_source(archive_root: Path, *, expected_raws: int) -> None: - """Census fixture raws, then record their explicit single-revision decision.""" - census = census_historical_revision_evidence(archive_root) - assert census.scanned == expected_raws - assert census.classified_full == expected_raws - with sqlite3.connect(archive_root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET revision_authority = 'byte_proven', - revision_kind = 'full', - source_revision = raw_id, - baseline_raw_id = raw_id, - predecessor_raw_id = NULL, - acquisition_generation = 0 - """ - ) - source.commit() - - def _run_verified_backup_cli(cli_runner: CliRunner, output_dir: Path, *, profile: str) -> Path: result = cli_runner.invoke( cli, @@ -2392,153 +2306,6 @@ def search_summaries(self, query: str, *, limit: int, origin: str | None) -> lis assert payload["hits"][0]["snippet"] == "[needle]" -@pytest.mark.parametrize( - "selection_mode", - ("all", "only-missing", "explicit"), - ids=["all", "only-missing", "explicit"], -) -def test_rebuild_index_source_replay_expands_every_execution_selection_to_authority_cohorts( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, - selection_mode: str, -) -> None: - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - key = "codex-session:authority-cohort" - source_path = "authority-cohort.jsonl" - session_meta = { - "type": "session_meta", - "payload": {"id": "authority-cohort", "timestamp": "2026-08-20T00:00:00Z"}, - } - baseline_message = { - "type": "response_item", - "payload": { - "type": "message", - "id": "message-1", - "role": "user", - "content": [{"type": "input_text", "text": "baseline"}], - }, - } - appended_message = { - "type": "response_item", - "payload": { - "type": "message", - "id": "message-2", - "role": "assistant", - "content": [{"type": "output_text", "text": "appended"}], - }, - } - baseline_payload = b"".join( - json.dumps(row, sort_keys=True).encode() + b"\n" for row in (session_meta, baseline_message) - ) - full_payload = baseline_payload + json.dumps(appended_message, sort_keys=True).encode() + b"\n" - baseline_raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=baseline_payload, - source_path=source_path, - acquired_at_ms=1, - native_id="authority-cohort", - ) - archive.bind_raw_revision( - baseline_raw_id, - RawRevisionEnvelope( - logical_source_key=key, - kind=RawRevisionKind.FULL, - source_revision="baseline", - acquisition_generation=0, - baseline_raw_id=baseline_raw_id, - authority=RawRevisionAuthority.BYTE_PROVEN, - ), - ) - full_raw_id = archive.write_raw_payload( - provider=Provider.CODEX, - payload=full_payload, - source_path=source_path, - acquired_at_ms=2, - native_id="authority-cohort", - ) - archive.bind_raw_revision( - full_raw_id, - RawRevisionEnvelope( - logical_source_key=key, - kind=RawRevisionKind.FULL, - source_revision="full", - acquisition_generation=1, - authority=RawRevisionAuthority.BYTE_PROVEN, - ), - ) - raw_ids = [baseline_raw_id, full_raw_id] - census_historical_revision_evidence(root) - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.classify_raw_revision_cohort_for_live_watch(key) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - selection_args = { - "all": [], - "only-missing": ["--only-missing"], - "explicit": ["--raw-id", raw_ids[0]], - }[selection_mode] - - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - *selection_args, - *(["--no-promote"] if selection_args else []), - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, result.output - payload = json.loads(result.stdout) - closure = payload["selection_evidence"]["replay_closure"] - assert closure["raw_ids"] == sorted(raw_ids) - assert {row["raw_id"] for row in closure["raw_session_evidence"]} == set(raw_ids) - with sqlite3.connect(root / "ops.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM ingest_attempts WHERE phase = 'rebuild-index'").fetchone() == (0,) - - -def test_rebuild_index_force_write_option_is_retired(cli_runner: CliRunner) -> None: - help_result = cli_runner.invoke(cli, ["ops", "maintenance", "rebuild-index", "--help"]) - result = cli_runner.invoke(cli, ["ops", "maintenance", "rebuild-index", "--force-write"]) - - assert help_result.exit_code == 0 - assert "--force-write" not in help_result.output - assert result.exit_code == 2 - assert "No such option" in result.output - assert "--force-write" in result.output - - -def test_rebuild_index_preflight_reports_durable_schema_currency( - cli_workspace: dict[str, Path], cli_runner: CliRunner -) -> None: - root = cli_workspace["archive_root"] - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {SOURCE_SCHEMA_VERSION - 1}") - - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--preflight", "--output-format", "json"], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - payload = json.loads(result.stdout) - assert payload["kind"] == "rebuild-schema-currency" - assert payload["status"] == "blocked" - assert [tier["tier"] for tier in payload["tiers"]] == ["audit", "source", "user"] - assert payload["blocking_tiers"][0]["tier"] == "source" - assert payload["blocking_tiers"][0]["actual_user_version"] == SOURCE_SCHEMA_VERSION - 1 - assert payload["blocking_tiers"][0]["expected_user_version"] == SOURCE_SCHEMA_VERSION - assert "migrate or deploy before rebuilding" in result.stderr - - def test_migrate_tier_cli_initializes_only_an_absent_durable_tier( cli_workspace: dict[str, Path], cli_runner: CliRunner ) -> None: @@ -3973,843 +3740,3 @@ def fail_or_race( else: assert not audit.exists() assert (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").is_file() - - -def test_rebuild_index_empty_source_still_runs_the_schema_currency_guard( - cli_workspace: dict[str, Path], cli_runner: CliRunner -) -> None: - root = cli_workspace["archive_root"] - with sqlite3.connect(root / "audit.db") as conn: - expected = int(conn.execute("PRAGMA user_version").fetchone()[0]) - conn.execute(f"PRAGMA user_version = {expected + 1}") - - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--output-format", "json"], - catch_exceptions=False, - ) - - assert result.exit_code == 1 - assert "audit.db" in result.stderr - assert not (root / ".index-generations").exists() - - -def test_rebuild_index_empty_source_preserves_plain_receipt_output_without_schema_receipt( - cli_workspace: dict[str, Path], cli_runner: CliRunner -) -> None: - """The real empty receipt must render without replay-only counter keys. - - Mutation: removing the status branch reaches the production counter - formatter and raises KeyError before this exact plain output is emitted. - """ - root = cli_workspace["archive_root"] - - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index"], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert result.stdout == f"Archive root: {root}\nNo source.db raw_sessions rows found.\n" - assert not (root / ".index-generations").exists() - - -def test_rebuild_index_rejects_daemon_schema_preflight_combination( - cli_workspace: dict[str, Path], cli_runner: CliRunner -) -> None: - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--preflight", "--daemon"], - catch_exceptions=False, - ) - - assert result.exit_code == 2 - assert "--preflight cannot be combined with --daemon" in result.output - - -def test_rebuild_index_daemon_path_posts_the_real_selection_request( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - captured: dict[str, object] = {} - receipt_path = write_valid_rebuild_receipt( - cli_workspace["archive_root"], cli_workspace["archive_root"].parent / "schema-inference-gate-receipt.json" - ) - - class Response: - def read(self) -> bytes: - return json.dumps( - { - "archive_root": str(cli_workspace["archive_root"]), - "classified_full_count": 2, - "replayed_logical_source_count": 1, - "quarantined_raw_count": 0, - } - ).encode() - - def __enter__(self) -> Response: - return self - - def __exit__(self, *_args: object) -> None: - return None - - def fake_urlopen(request: object, *, timeout: int) -> Response: - captured["url"] = request.full_url # type: ignore[attr-defined] - captured["body"] = json.loads(request.data) # type: ignore[attr-defined] - captured["timeout"] = timeout - return Response() - - monkeypatch.setattr(maintenance_rebuild_index, "urlopen", fake_urlopen) - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--daemon", - "--daemon-url", - "http://127.0.0.1:9876", - "--schema-inference-receipt", - str(receipt_path), - "--raw-batch-size", - "17", - "--pass-byte-budget-mb", - "12.5", - "--pass-deadline-seconds", - "45", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert captured == { - "url": "http://127.0.0.1:9876/api/maintenance/rebuild-index", - "body": { - "only_missing": False, - "raw_ids": [], - "max_blob_mb": None, - "promote": True, - "operation_id": None, - "schema_inference_receipt_path": str(receipt_path), - "raw_batch_size": 17, - "pass_byte_budget_mb": 12.5, - "pass_deadline_seconds": 45.0, - }, - "timeout": 600, - } - assert "Classified:" in result.output - - -def test_rebuild_index_daemon_resolves_relative_schema_receipt_before_post( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """Relative receipt references are resolved before daemon serialization. - - Anti-vacuity: removing CLI-side resolution sends the relative filename in - the captured production daemon payload. - """ - root = cli_workspace["archive_root"] - absolute_receipt = write_valid_rebuild_receipt(root, tmp_path / "relative-receipt.json") - monkeypatch.chdir(tmp_path) - captured: dict[str, object] = {} - - class Response: - def read(self) -> bytes: - return json.dumps( - { - "archive_root": str(root), - "classified_full_count": 0, - "replayed_logical_source_count": 0, - "quarantined_raw_count": 0, - } - ).encode() - - def __enter__(self) -> Response: - return self - - def __exit__(self, *_args: object) -> None: - return None - - def fake_urlopen(request: object, *, timeout: int) -> Response: - captured["body"] = json.loads(request.data) # type: ignore[attr-defined] - return Response() - - monkeypatch.setattr(maintenance_rebuild_index, "urlopen", fake_urlopen) - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--daemon", - "--schema-inference-receipt", - absolute_receipt.name, - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - body = captured["body"] - assert isinstance(body, dict) - assert body["schema_inference_receipt_path"] == str(absolute_receipt.resolve()) - - -@pytest.mark.parametrize("selection_args", [["--only-missing"], ["--raw-id", "raw-a"]]) -def test_partial_rebuild_requires_no_promote_before_archive_mutation( - cli_workspace: dict[str, Path], cli_runner: CliRunner, selection_args: list[str] -) -> None: - index_path = cli_workspace["archive_root"] / "index.db" - inode_before = index_path.stat().st_ino - generations_before = tuple(cli_workspace["archive_root"].glob(".index-generations/*")) - - result = cli_runner.invoke(cli, ["--plain", "ops", "maintenance", "rebuild-index", *selection_args]) - - assert result.exit_code == 2 - assert "partial rebuild selections require --no-promote" in result.output - assert index_path.stat().st_ino == inode_before - assert tuple(cli_workspace["archive_root"].glob(".index-generations/*")) == generations_before - - -def test_all_index_rebuild_raw_ids_uses_source_acquisition_order( - cli_workspace: dict[str, Path], -) -> None: - source_db = cli_workspace["archive_root"] / "source.db" - with sqlite3.connect(source_db) as conn: - initialize_archive_tier(conn, ArchiveTier.SOURCE) - for raw_id, acquired_at_ms in ( - ("raw-child", 30), - ("raw-parent", 10), - ("raw-sibling-b", 20), - ("raw-sibling-a", 20), - ): - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, - blob_size, acquired_at_ms, validation_status - ) - VALUES (?, 'codex-session', ?, ?, 0, randomblob(32), 1, ?, 'passed') - """, - (raw_id, raw_id, f"/tmp/{raw_id}.jsonl", acquired_at_ms), - ) - - assert maintenance_rebuild_index._all_index_rebuild_raw_ids(cli_workspace["archive_root"]) == [ - "raw-parent", - "raw-sibling-a", - "raw-sibling-b", - "raw-child", - ] - - -def test_rebuild_index_full_source_resumes_one_candidate_until_terminal_promotion( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - """A bounded pass retains its generation; only the terminal resume promotes it.""" - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - for native_id, acquired_at_ms in (("first", 1), ("second", 2)): - archive.write_raw_payload( - provider=Provider.CODEX, - payload=( - f'{{"type":"session_meta","payload":{{"id":"{native_id}"}}}}\n' - f'{{"type":"response_item","payload":{{"type":"message","role":"user",' - f'"content":[{{"type":"input_text","text":"{native_id}"}}]}}}}\n' - ).encode(), - source_path=f"{native_id}.jsonl", - acquired_at_ms=acquired_at_ms, - ) - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET logical_source_key = CASE - WHEN source_path = 'first.jsonl' THEN 'codex:first' - ELSE 'codex:second' - END, - revision_kind = 'full', - source_revision = raw_id, - baseline_raw_id = raw_id, - acquisition_generation = 0, - revision_authority = 'byte_proven' - """ - ) - source.commit() - _freeze_rebuild_fixture_source(root, expected_raws=2) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - - first = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--raw-batch-size", "1", "--output-format", "json"], - catch_exceptions=False, - ) - assert first.exit_code == 0, first.output - # This pass now also replays a raw page through the shared - # revision-backfill machinery, which logs "backfill stage timings" to - # stderr on every call (see the sibling terminal-promotion test for the - # full rationale); `.stdout` is the actual `--output-format json` - # contract surface, `.output` is Click 8.4's always-mixed stream. - first_payload = json.loads(first.stdout) - operation_id = first_payload["transaction"]["operation_id"] - generation_path = Path(first_payload["generation"]["index_path"]) - assert first_payload["status"] == "paused" - assert first_payload["transaction"]["processed_raw_count"] == 1 - assert generation_path.exists() - assert root.joinpath("index.db").resolve() != generation_path.resolve() - - terminal = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--operation-id", - operation_id, - "--raw-batch-size", - "1", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert terminal.exit_code == 0 - # Promotion logs `rebuild_terminal_stage_complete` structlog events to - # stderr per invocation (one per terminal stage) -- correct per the - # stdout=results/stderr=diagnostics channel-separation contract - # (test_stdout_stderr_split.py), but `.output` is Click 8.4's always- - # mixed stream (mix_stderr was removed; `.output` "mixes stdout and - # stderr, in the order they were written"). `.stdout` is the actual - # `--output-format json` contract surface. - terminal_payload = json.loads(terminal.stdout) - assert terminal_payload["status"] == "replayed" - assert terminal_payload["transaction"]["status"] == "promoted" - assert root.joinpath("index.db").resolve() == generation_path.resolve() - with sqlite3.connect(root / "index.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (2,) - - -def test_rebuild_index_persists_durable_pass_receipt_alongside_transaction( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - """Every rebuild pass receipt survives on disk, not only on the CLI's stdout. - - Reproduces the fix for a live incident (polylogue-k8kj): two rebuild page - receipts were lost because the CLI writes the receipt JSON only to - stdout, and the invoking shell's pipe died while an orphaned rebuild - process kept working. Each pass must also be durably persisted under the - transaction directory so a lost pipe never means a lost receipt. - """ - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - for native_id, acquired_at_ms in (("first", 1), ("second", 2)): - archive.write_raw_payload( - provider=Provider.CODEX, - payload=( - f'{{"type":"session_meta","payload":{{"id":"{native_id}"}}}}\n' - f'{{"type":"response_item","payload":{{"type":"message","role":"user",' - f'"content":[{{"type":"input_text","text":"{native_id}"}}]}}}}\n' - ).encode(), - source_path=f"{native_id}.jsonl", - acquired_at_ms=acquired_at_ms, - ) - with sqlite3.connect(root / "source.db") as source: - source.execute( - """ - UPDATE raw_sessions - SET logical_source_key = CASE - WHEN source_path = 'first.jsonl' THEN 'codex:first' - ELSE 'codex:second' - END, - revision_kind = 'full', - source_revision = raw_id, - baseline_raw_id = raw_id, - acquisition_generation = 0, - revision_authority = 'byte_proven' - """ - ) - source.commit() - _freeze_rebuild_fixture_source(root, expected_raws=2) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - - first = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--raw-batch-size", "1", "--output-format", "json"], - catch_exceptions=False, - ) - assert first.exit_code == 0, first.output - # This pass now also replays a raw page through the shared - # revision-backfill machinery, which logs "backfill stage timings" to - # stderr on every call (see the sibling terminal-promotion test for the - # full rationale); `.stdout` is the actual `--output-format json` - # contract surface, `.output` is Click 8.4's always-mixed stream. - first_payload = json.loads(first.stdout) - operation_id = first_payload["transaction"]["operation_id"] - assert first_payload["status"] == "paused" - - receipts_dir = root / ".index-rebuild-transactions" / f"{operation_id}.receipts" - receipt_files = sorted(receipts_dir.glob("pass-*.json")) - assert len(receipt_files) == 1 - persisted = json.loads(receipt_files[0].read_text(encoding="utf-8")) - assert persisted["status"] == "paused" - assert persisted["transaction"]["operation_id"] == operation_id - - terminal = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--operation-id", - operation_id, - "--raw-batch-size", - "1", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert terminal.exit_code == 0 - # Promotion logs to stderr (see the sibling terminal-promotion test for - # the full rationale); `.stdout` is the actual `--output-format json` - # contract surface, `.output` is Click 8.4's always-mixed stream. - terminal_payload = json.loads(terminal.stdout) - assert terminal_payload["status"] == "replayed" - - receipt_files = sorted(receipts_dir.glob("pass-*.json")) - assert len(receipt_files) == 2 - final_persisted = json.loads(receipt_files[-1].read_text(encoding="utf-8")) - assert final_persisted["status"] == "replayed" - - -def test_rebuild_index_byte_budget_defers_then_reaches_terminal_ready_candidate( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - """The real CLI replays every raw over passes; byte budgeting never filters archive data.""" - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - for native_id, acquired_at_ms, padding in (("large", 1, "x" * 1_024), ("later", 2, "y")): - archive.write_raw_payload( - provider=Provider.CODEX, - payload=( - f'{{"type":"session_meta","payload":{{"id":"{native_id}"}}}}\n' - f'{{"type":"response_item","payload":{{"type":"message","role":"user",' - f'"content":[{{"type":"input_text","text":"{padding}"}}]}}}}\n' - ).encode(), - source_path=f"{native_id}.jsonl", - acquired_at_ms=acquired_at_ms, - ) - _freeze_rebuild_fixture_source(root, expected_raws=2) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - first = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--pass-byte-budget-mb", - "0.0001", - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert first.exit_code == 0, first.output - # This pass now also replays a raw page through the shared - # revision-backfill machinery, which logs "backfill stage timings" to - # stderr on every call (see the sibling terminal-promotion test for the - # full rationale); `.stdout` is the actual `--output-format json` - # contract surface, `.output` is Click 8.4's always-mixed stream. - first_payload = json.loads(first.stdout) - assert first_payload["status"] == "deferred" - operation_id = first_payload["transaction"]["operation_id"] - terminal = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--operation-id", - operation_id, - "--no-promote", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert terminal.exit_code == 0 - # Terminal-stage-complete events (session_insights/bulk_build.*/fts_parity/ - # readiness) log to stderr even with --no-promote; `.stdout` is the - # actual `--output-format json` contract surface, `.output` is Click - # 8.4's always-mixed stream. - payload = json.loads(terminal.stdout) - assert payload["transaction"]["status"] == "ready" - assert payload["generation"]["state"] == "inactive" - with sqlite3.connect(Path(payload["generation"]["index_path"])) as conn: - assert conn.execute("SELECT COUNT(*) FROM sessions").fetchone() == (2,) - - -def test_rebuild_index_source_snapshot_drift_fails_before_candidate_creation( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - """A receipt/source mismatch stops the route before candidate creation.""" - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.write_raw_payload( - provider=Provider.CODEX, - payload=( - b'{"type":"session_meta","payload":{"id":"drift"}}\n' - b'{"type":"response_item","payload":{"type":"message","role":"user",' - b'"content":[{"type":"input_text","text":"drift"}]}}\n' - ), - source_path="drift.jsonl", - acquired_at_ms=1, - ) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.write_raw_payload( - provider=Provider.CODEX, - payload=b'{"type":"session_meta","payload":{"id":"drift-after-receipt"}}\n', - source_path="drift-after-receipt.jsonl", - acquired_at_ms=2, - ) - result = cli_runner.invoke( - cli, - ["--plain", "ops", "maintenance", "rebuild-index", "--output-format", "json"], - catch_exceptions=False, - ) - assert result.exit_code == 1 - assert "schema-inference preflight gate failed" in result.output - assert not list((root / ".index-rebuild-transactions").glob("*.json")) - assert not list((root / ".index-generations").glob("gen-*")) - assert not root.joinpath("index.db").is_symlink() - - -def test_rebuild_index_deadline_defers_postflight_until_resume( - cli_workspace: dict[str, Path], cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch -) -> None: - """Deadline expiry preserves the replayed candidate instead of permitting early promotion.""" - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - root = cli_workspace["archive_root"] - with ArchiveStore.open_existing(root, read_only=False) as archive: - archive.write_raw_payload( - provider=Provider.CODEX, - payload=( - b'{"type":"session_meta","payload":{"id":"deadline"}}\n' - b'{"type":"response_item","payload":{"type":"message","role":"user",' - b'"content":[{"type":"input_text","text":"deadline"}]}}\n' - ), - source_path="deadline.jsonl", - acquired_at_ms=1, - ) - _freeze_rebuild_fixture_source(root, expected_raws=1) - receipt_path = write_valid_rebuild_receipt(root, root.parent / "schema-inference-gate-receipt.json") - monkeypatch.setenv("POLYLOGUE_SCHEMA_INFERENCE_RECEIPT", str(receipt_path)) - # `monkeypatch.setattr("polylogue.maintenance.rebuild_index.time.time", ...)` - # patches the *stdlib* `time` module's `time` attribute (modules are - # process-wide singletons), not a private copy scoped to rebuild_index.py. - # Use a monotonically-advancing fake clock rather than pinning an exact - # call count before/between the deadline reads this test exercises. - # - # polylogue-uhgm: the deadline is now ALSO checked between replay - # cohorts (not only once, post-hoc, after the whole page replayed), so - # an ever-advancing clock left active across BOTH invocations would trip - # the resumed pass's very first between-cohorts check too and never let - # it promote. Scope the fake clock to just the first (interrupted) - # invocation with `monkeypatch.context()`; the resumed invocation runs - # on the real clock against the transaction's durable 1s budget, which - # is ample for this single-raw fixture. - fake_clock = itertools.count(100.0, 50.0) - with pytest.MonkeyPatch.context() as clock_patch: - clock_patch.setattr( - "polylogue.maintenance.rebuild_index.time.time", - lambda: next(fake_clock), - ) - first = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--pass-deadline-seconds", - "1", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert first.exit_code == 0, first.output - # This pass replays a raw page through the shared revision-backfill - # machinery, which logs "backfill stage timings" to stderr on every - # call; `.stdout` is the actual `--output-format json` contract - # surface, `.output` is Click 8.4's always-mixed stream. - payload = json.loads(first.stdout) - assert payload["status"] == "deferred" - assert payload["transaction"]["status"] == "deferred" - # polylogue-uhgm: the deadline fired before the sole raw in this page - # was ever replayed (not merely observed too late afterward), so this - # pass recorded zero forward progress -- the whole point of the fix. - assert payload["transaction"]["processed_raw_count"] == 0 - assert payload["transaction"]["last_raw_id"] is None - assert not root.joinpath("index.db").is_symlink() - resumed = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--operation-id", - payload["transaction"]["operation_id"], - "--output-format", - "json", - ], - catch_exceptions=False, - ) - assert resumed.exit_code == 0 - # This resume promotes, logging terminal-stage-complete events to stderr; - # `.stdout` is the actual `--output-format json` contract surface, - # `.output` is Click 8.4's always-mixed stream. - assert json.loads(resumed.stdout)["transaction"]["status"] == "promoted" - - -def test_rebuild_index_helper_returns_typed_empty_replay_receipt(tmp_path: Path) -> None: - config = Config( - archive_root=tmp_path, - render_root=tmp_path / "render", - sources=[], - db_path=tmp_path / "index.db", - ) - - result = asyncio.run( - rebuild_index_from_source( - config, - raw_ids=["raw-a", "raw-b"], - raw_batch_size=7, - ingest_workers=1, - materialize=True, - progress_callback=None, - ) - ) - - # rebuild_index_from_source now also reports parse_s/apply_s/stage_timings_s - # (the bulk-build pragma-profile timing instrumentation for owned inactive - # generations). Those are wall-clock floats, not part of this helper's - # typed-empty-receipt contract, so they are checked for shape/presence - # separately rather than folded into the exact-count comparison below. - timing_keys = {"parse_s", "apply_s", "stage_timings_s", "whale_envelope"} - assert timing_keys.issubset(result) - assert isinstance(result["parse_s"], float) - assert isinstance(result["apply_s"], float) - assert isinstance(result["stage_timings_s"], dict) - assert isinstance(result["whale_envelope"], dict) - assert {key: value for key, value in result.items() if key not in timing_keys} == { - "scanned_raw_count": 0, - "classified_full_count": 0, - "replayed_logical_source_count": 0, - "quarantined_raw_count": 0, - "adoption_deferred_raw_count": 0, - "authority_selection_expanded": True, - "scheduled_raw_count": 2, - "raw_batch_size": 7, - "ingest_workers": 1, - } - - -def test_rebuild_index_explicit_raw_ids_remain_inspectable_in_plan_mode( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - "polylogue.cli.commands.maintenance._rebuild_index._count_source_raw_sessions", lambda _root: 10 - ) - - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--raw-id", - "raw-a", - "--raw-id", - "raw-b", - "--raw-id", - "raw-a", - "--plan", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["raw_session_count"] == 10 - assert payload["selected_raw_count"] == 2 - assert payload["raw_id_count"] == 3 - assert payload["skipped_by_blob_limit_count"] == 0 - assert payload["status"] == "ok" - - -def test_rebuild_index_filters_selected_rows_by_blob_size( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, -) -> None: - archive_root = cli_workspace["archive_root"] - source_db = archive_root / "source.db" - with sqlite3.connect(source_db) as conn: - initialize_archive_tier(conn, ArchiveTier.SOURCE) - rows = [ - ("raw-small", 1 * 1024 * 1024, 2), - ("raw-large", 3 * 1024 * 1024, 1), - ] - for raw_id, blob_size, acquired_at_ms in rows: - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, - blob_size, acquired_at_ms, validation_status - ) - VALUES (?, 'codex-session', ?, ?, 0, randomblob(32), ?, ?, 'passed') - """, - (raw_id, raw_id, f"/tmp/{raw_id}.jsonl", blob_size, acquired_at_ms), - ) - - monkeypatch.setattr("polylogue.cli.commands.maintenance._rebuild_index._count_source_raw_sessions", lambda _root: 2) - monkeypatch.setattr( - "polylogue.cli.commands.maintenance._rebuild_index._missing_index_raw_ids", - lambda _root: ["raw-large", "raw-small"], - ) - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--only-missing", - "--max-blob-mb", - "2", - "--plan", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["selected_raw_count"] == 1 - assert payload["totals"]["blob_bytes"] == 1 * 1024 * 1024 - assert payload["skipped_by_blob_limit_count"] == 1 - assert payload["max_blob_mb"] == 2.0 - assert [row["raw_id"] for row in payload["top_rows"]] == ["raw-small"] - - -def test_rebuild_index_plan_reports_weighted_top_rows( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, -) -> None: - archive_root = cli_workspace["archive_root"] - source_db = archive_root / "source.db" - index_db = archive_root / "index.db" - with sqlite3.connect(source_db) as conn: - initialize_archive_tier(conn, ArchiveTier.SOURCE) - for raw_id, native_id, source_path, source_index, blob_size, acquired_at_ms in ( - ("raw-small", "small", "/tmp/raw-small.jsonl", 0, 1_000, 1), - ("raw-large", "large", "/tmp/raw-large.jsonl", 0, 5_000, 2), - ("raw-large-2", "large-2", "/tmp/raw-large.jsonl", 1, 3_000, 3), - ): - conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, - blob_size, acquired_at_ms, validation_status - ) - VALUES (?, 'codex-session', ?, ?, ?, randomblob(32), ?, ?, 'passed') - """, - (raw_id, native_id, source_path, source_index, blob_size, acquired_at_ms), - ) - with sqlite3.connect(index_db) as conn: - initialize_archive_tier(conn, ArchiveTier.INDEX) - conn.execute( - """ - INSERT INTO sessions (native_id, origin, raw_id, message_count, content_hash) - VALUES ('large', 'codex-session', 'raw-large', 42, randomblob(32)) - """ - ) - session_id = conn.execute("SELECT session_id FROM sessions WHERE raw_id = 'raw-large'").fetchone()[0] - conn.execute( - """ - INSERT INTO session_events (session_id, position, event_type, summary) - VALUES (?, 0, 'capture_gap', 'gap') - """, - (session_id,), - ) - - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "maintenance", - "rebuild-index", - "--plan", - "--plan-limit", - "1", - "--output-format", - "json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = json.loads(result.stdout) - assert payload["status"] == "ok" - assert payload["raw_session_count"] == 3 - assert payload["selected_raw_count"] == 3 - assert payload["replay_order"] == "blob_hash_asc_raw_id_asc" - assert payload["risk_order"] == "blob_size_desc" - assert payload["cost_basis"]["primary"] == "source.db raw_sessions.blob_size" - assert payload["totals"]["blob_bytes"] == 9_000 - assert payload["totals"]["materialized_messages"] == 42 - assert payload["totals"]["materialized_session_events"] == 1 - assert [row["raw_id"] for row in payload["top_rows"]] == ["raw-large"] - assert payload["top_rows"][0]["materialized_messages"] == 42 - assert payload["top_groups"] == [ - { - "origin": "codex-session", - "native_id": "large", - "source_path": "/tmp/raw-large.jsonl", - "row_count": 2, - "blob_bytes": 8_000, - "first_acquired_at_ms": 2, - "last_acquired_at_ms": 3, - "materialized_sessions": 1, - "materialized_messages": 42, - "materialized_session_events": 1, - } - ] diff --git a/tests/unit/cli/test_check.py b/tests/unit/cli/test_check.py index fbbc36d03c..8543f4a2ee 100644 --- a/tests/unit/cli/test_check.py +++ b/tests/unit/cli/test_check.py @@ -35,9 +35,8 @@ json_int, json_object, json_object_field, - parse_json_object, ) -from tests.infra.storage_records import DbFactory, SessionBuilder +from tests.infra.storage_records import DbFactory pytestmark = pytest.mark.uses_real_clock( "Constructs a wall-clock anchor for doctor envelope assertions; production envelope embeds the same now() within the same call." @@ -52,15 +51,6 @@ def cli_runner() -> CliRunner: return CliRunner() -def _rebuild_native_insights(db_path: Path) -> None: - """Materialize session insights for a seeded index.db.""" - from polylogue.storage.insights.session.rebuild import rebuild_archive_session_insights - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - - with ArchiveStore.open_existing(db_path.parent, read_only=False) as archive: - rebuild_archive_session_insights(archive) - - def _find_named_check(payload: JSONDocument, name: str) -> JSONDocument: checks = json_array_field(payload, "checks", context="check payload") for check in checks: @@ -168,94 +158,6 @@ def test_health_report_empty_checks(self) -> None: assert report.summary == {"ok": 0, "warning": 0, "error": 0} -def test_check_records_scoped_maintenance_preview(cli_workspace: WorkspacePaths, cli_runner: CliRunner) -> None: - db_path = cli_workspace["db_path"] - old_timestamp = "2020-01-01T00:00:00+00:00" - ( - SessionBuilder(db_path, "conv-check-insights") - .provider("claude-code") - .title("Scoped Check Repair") - .updated_at(old_timestamp) - .add_message("u1", role="user", text="Plan the cleanup", timestamp=old_timestamp) - .save() - ) - _rebuild_native_insights(db_path) - with open_index_db(db_path) as conn: - conn.execute("DELETE FROM session_profiles") - conn.commit() - - result = cli_runner.invoke( - cli, - [ - "ops", - "doctor", - "--format", - "json", - "--repair", - "--preview", - "--target", - "session_insights", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = _extract_json(result.output) - maintenance = json_object_field(payload, "maintenance", context="check payload") - assert maintenance.get("targets") == ["session_insights"] - maintenance_item = json_array_item( - json_array_field(maintenance, "items", context="maintenance"), 0, context="maintenance.items" - ) - assert maintenance_item.get("name") == "session_insights" - # `_targeted_session_insight_rebuild_ids` (polylogue/storage/repair.py) - # returns DISTINCT session_ids needing repair; this fixture seeds exactly - # one session, so at most one session can ever be pending here. - assert maintenance_item.get("repaired_count") == 1 - - -def test_check_records_scoped_maintenance_apply(cli_workspace: WorkspacePaths, cli_runner: CliRunner) -> None: - db_path = cli_workspace["db_path"] - old_timestamp = "2020-01-01T00:00:00+00:00" - ( - SessionBuilder(db_path, "conv-check-insights-apply") - .provider("claude-code") - .title("Scoped Check Repair Apply") - .updated_at(old_timestamp) - .add_message("u1", role="user", text="Repair the durable insights", timestamp=old_timestamp) - .save() - ) - _rebuild_native_insights(db_path) - with open_index_db(db_path) as conn: - conn.execute("DELETE FROM session_profiles") - conn.commit() - - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "doctor", - "--deep", - "--format", - "json", - "--repair", - "--target", - "session_insights", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = _extract_json(result.output) - maintenance = json_object_field(payload, "maintenance", context="check payload") - assert maintenance.get("targets") == ["session_insights"] - maintenance_item = json_array_item( - json_array_field(maintenance, "items", context="maintenance"), 0, context="maintenance.items" - ) - assert maintenance_item.get("name") == "session_insights" - assert maintenance_item.get("success") is True - - def test_check_daemon_json_uses_shared_daemon_status(cli_runner: CliRunner) -> None: daemon_report: JSONDocument = { "ok": True, @@ -298,45 +200,6 @@ def test_check_daemon_plain_renders_component_status(cli_runner: CliRunner) -> N assert "Browser capture spool: ready" in result.output -def test_check_plain_preview_summarizes_changes_not_issues( - cli_workspace: WorkspacePaths, cli_runner: CliRunner -) -> None: - db_path = cli_workspace["db_path"] - old_timestamp = "2020-01-01T00:00:00+00:00" - ( - SessionBuilder(db_path, "conv-check-insights-preview-plain") - .provider("claude-code") - .title("Scoped Check Repair Preview Plain") - .updated_at(old_timestamp) - .add_message("u1", role="user", text="Preview the repair output", timestamp=old_timestamp) - .save() - ) - _rebuild_native_insights(db_path) - with open_index_db(db_path) as conn: - conn.execute("DELETE FROM session_profiles") - conn.commit() - - result = cli_runner.invoke( - cli, - [ - "--plain", - "ops", - "doctor", - "--deep", - "--repair", - "--preview", - "--target", - "session_insights", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert "Would apply" in result.output - assert "change(s)" in result.output - assert "issue(s)" not in result.output - - def test_check_warns_when_message_index_is_incomplete(cli_workspace: WorkspacePaths, cli_runner: CliRunner) -> None: db_path = cli_workspace["db_path"] factory = DbFactory(db_path) @@ -470,10 +333,6 @@ def test_check_runtime_only_skips_archive_readiness(self, monkeypatch: pytest.Mo options = CheckCommandOptions( json_output=True, verbose=False, - repair=False, - cleanup=False, - preview=False, - vacuum=False, deep=False, runtime=True, check_daemon=False, @@ -493,7 +352,6 @@ def test_check_runtime_only_skips_archive_readiness(self, monkeypatch: pytest.Mo schema_record_limit=None, schema_record_offset=0, schema_quarantine_malformed=False, - maintenance_targets=(), ) monkeypatch.setattr( @@ -778,8 +636,6 @@ def test_check_rejects_retired_provider_flags_with_origin_hint( # --- Flag validation: invalid combos rejected with correct error --- INVALID_FLAG_COMBOS = [ - (["ops", "doctor", "--vacuum"], "--vacuum requires --repair or --cleanup"), - (["ops", "doctor", "--preview"], "--preview requires --repair or --cleanup"), (["ops", "doctor", "--schema-origin", "chatgpt"], "--schema-origin requires --schemas"), (["ops", "doctor", "--schema-record-limit", "100"], "--schema-record-limit requires --schemas"), (["ops", "doctor", "--schema-record-offset", "10"], "--schema-record-offset requires --schemas"), @@ -811,71 +667,6 @@ def test_invalid_flag_combinations_rejected( # --- Remaining non-repetitive tests --- - def test_json_output_with_repair(self, cli_workspace: WorkspacePaths) -> None: - """--format json with --repair includes maintenance results.""" - from click.testing import CliRunner - - from polylogue.cli.click_app import cli - - runner = CliRunner() - result = runner.invoke(cli, ["ops", "doctor", "--format", "json", "--repair", "--preview"]) - assert result.exit_code == 0 - envelope = parse_json_object( - result.output.split("\n", 1)[-1] if "Plain" in result.output else result.output, - context="repair preview envelope", - ) - data = json_object(envelope.get("result", envelope), context="repair preview payload") - assert "maintenance" in data - maintenance = json_object_field(data, "maintenance", context="repair preview payload") - assert "resource_boundary" not in maintenance - - def test_repair_with_no_issues_shows_message(self, cli_workspace: WorkspacePaths) -> None: - """When repair finds no issues, should show a maintenance status message.""" - from click.testing import CliRunner - - from polylogue.cli.click_app import cli - - runner = CliRunner() - result = runner.invoke(cli, ["ops", "doctor", "--repair"]) - assert result.exit_code == 0 - assert ( - "No selected maintenance work" in result.output - or "Changed" in result.output - or "maintenance" in result.output.lower() - ) - - def test_vacuum_with_repair(self, cli_workspace: WorkspacePaths) -> None: - """--vacuum with --repair should attempt VACUUM.""" - from click.testing import CliRunner - - from polylogue.cli.click_app import cli - - runner = CliRunner() - result = runner.invoke(cli, ["ops", "doctor", "--repair", "--vacuum"]) - assert result.exit_code == 0 - assert "VACUUM" in result.output - - def test_json_output_with_repair_and_vacuum_is_machine_safe(self, cli_workspace: WorkspacePaths) -> None: - """`--format json --repair --vacuum` should stay valid JSON.""" - from click.testing import CliRunner - - from polylogue.cli.click_app import cli - - runner = CliRunner() - result = runner.invoke( - cli, ["--plain", "ops", "doctor", "--format", "json", "--repair", "--preview", "--vacuum"] - ) - - assert result.exit_code == 0 - envelope = parse_json_object(result.output, context="repair vacuum envelope") - data = json_object(envelope.get("result", envelope), context="repair vacuum payload") - assert "maintenance" in data - maintenance = json_object_field(data, "maintenance", context="repair vacuum payload") - assert "resource_boundary" not in maintenance - vacuum = json_object_field(data, "vacuum", context="repair vacuum payload") - assert vacuum.get("ok") is True - assert vacuum.get("preview") is True - def test_check_schemas_json_output(self, cli_workspace: WorkspacePaths) -> None: """--schemas adds schema_verification block to JSON output.""" from click.testing import CliRunner diff --git a/tests/unit/cli/test_check_rendering_plain_runtime.py b/tests/unit/cli/test_check_rendering_plain_runtime.py index 62408bd042..f7fad1ee04 100644 --- a/tests/unit/cli/test_check_rendering_plain_runtime.py +++ b/tests/unit/cli/test_check_rendering_plain_runtime.py @@ -8,7 +8,6 @@ from polylogue.cli.shared.check_models import CheckCommandResult from polylogue.cli.shared.check_rendering_plain import ( build_report_lines, - emit_maintenance_output, render_plain_output, status_icon, ) @@ -31,10 +30,6 @@ def _options(**overrides: object) -> CheckCommandOptions: values = { "json_output": False, "verbose": False, - "repair": False, - "cleanup": False, - "preview": False, - "vacuum": False, "deep": False, "runtime": False, "check_daemon": False, @@ -54,7 +49,6 @@ def _options(**overrides: object) -> CheckCommandOptions: "schema_record_limit": None, "schema_record_offset": 0, "schema_quarantine_malformed": False, - "maintenance_targets": (), } values.update(overrides) return CheckCommandOptions(**values) @@ -203,63 +197,13 @@ def test_build_report_lines_renders_all_sections_and_breakdowns() -> None: assert "Browser capture spool: ready" in rendered -def test_emit_maintenance_output_handles_preview_empty_selection_and_vacuum_modes() -> None: - env = _env(plain=True) - result = CheckCommandResult( - report=ReadinessReport(), - maintenance_results=[ - SimpleNamespace( - repaired_count=2, - success=True, - category=SimpleNamespace(value="repair"), - destructive=False, - name="fts", - detail="rebuilt", - ), - SimpleNamespace( - repaired_count=0, - success=False, - category=SimpleNamespace(value="cleanup"), - destructive=True, - name="orphans", - detail="failed", - ), - ], - maintenance_targets=("session_insights",), - ) - with patch("polylogue.cli.shared.check_rendering_plain.run_vacuum") as run_vacuum: - emit_maintenance_output(env, result, _options(repair=True, preview=True, vacuum=True)) - run_vacuum.assert_not_called() - - printed = "\n".join(call.args[0] for call in env.ui.console.print.call_args_list) - assert "OK fts [repair]: rebuilt" in printed - assert "FAIL orphans [cleanup destructive]: failed" in printed - assert "Preview mode: VACUUM skipped." in printed - - env_no_selection = _env(plain=False) - with patch("polylogue.cli.shared.check_rendering_plain.run_vacuum") as run_vacuum: - emit_maintenance_output( - env_no_selection, - CheckCommandResult(report=ReadinessReport(), maintenance_results=None), - _options(cleanup=True, vacuum=True), - ) - run_vacuum.assert_called_once_with(env_no_selection) - - assert any( - "No maintenance operations were selected." in call.args[0] - for call in env_no_selection.ui.console.print.call_args_list - ) - - -def test_render_plain_output_delegates_to_summary_and_maintenance() -> None: +def test_render_plain_output_delegates_to_summary() -> None: env = _env(plain=True) result = CheckCommandResult(report=ReadinessReport()) options = _options() with patch("polylogue.cli.shared.check_rendering_plain.build_report_lines", return_value=["alpha"]) as build_lines: - with patch("polylogue.cli.shared.check_rendering_plain.emit_maintenance_output") as emit_maintenance: - render_plain_output(env, result, options) + render_plain_output(env, result, options) build_lines.assert_called_once_with(env, result, options) env.ui.summary.assert_called_once_with("Health Check", ["alpha"]) - emit_maintenance.assert_called_once_with(env, result, options) diff --git a/tests/unit/cli/test_check_support_runtime.py b/tests/unit/cli/test_check_support_runtime.py index 169f199550..15fdd4f413 100644 --- a/tests/unit/cli/test_check_support_runtime.py +++ b/tests/unit/cli/test_check_support_runtime.py @@ -8,15 +8,12 @@ import pytest from polylogue.cli.shared import check_workflow -from polylogue.cli.shared.check_models import CheckCommandResult, VacuumResult from polylogue.cli.shared.check_workflow import CheckCommandOptions from polylogue.cli.shared.types import AppEnv from polylogue.config import Config from polylogue.core.json import JSONDocument -from polylogue.maintenance.targets import MaintenanceTargetMode from polylogue.readiness import ReadinessCheck, ReadinessReport, VerifyStatus from polylogue.schemas.validation.models import SchemaVerificationReport -from polylogue.storage.repair import RepairResult def _env() -> AppEnv: @@ -42,10 +39,6 @@ def _options(**overrides: object) -> CheckCommandOptions: payload = { "json_output": False, "verbose": False, - "repair": False, - "cleanup": False, - "preview": False, - "vacuum": False, "deep": False, "runtime": False, "check_daemon": False, @@ -65,7 +58,6 @@ def _options(**overrides: object) -> CheckCommandOptions: "schema_record_limit": None, "schema_record_offset": 0, "schema_quarantine_malformed": False, - "maintenance_targets": (), } payload.update(overrides) return CheckCommandOptions(**cast(Any, payload)) @@ -74,9 +66,6 @@ def _options(**overrides: object) -> CheckCommandOptions: @pytest.mark.parametrize( ("overrides", "expected"), [ - ({"vacuum": True}, "--vacuum requires --repair or --cleanup"), - ({"preview": True}, "--preview requires --repair or --cleanup"), - ({"maintenance_targets": ("session_insights",)}, "--target requires --repair or --cleanup"), ({"schema_providers": ("claude-code",)}, "--schema-origin requires --schemas"), ({"schema_samples": "10"}, "--schema-samples requires --schemas"), ({"schema_record_limit": 5}, "--schema-record-limit requires --schemas"), @@ -104,19 +93,6 @@ def test_validate_check_options_rejects_invalid_flag_combinations( check_workflow.validate_check_options(_options(**overrides)) -def test_validate_check_options_rejects_target_mode_mismatches() -> None: - cleanup_spec = SimpleNamespace(mode=MaintenanceTargetMode.CLEANUP) - repair_spec = SimpleNamespace(mode=MaintenanceTargetMode.REPAIR) - catalog = SimpleNamespace(resolve=lambda names: [cleanup_spec] if names == ("cleanup_only",) else [repair_spec]) - - with patch("polylogue.cli.shared.check_validation.build_maintenance_target_catalog", return_value=catalog): - with pytest.raises(SystemExit, match="only selected cleanup targets"): - check_workflow.validate_check_options(_options(repair=True, maintenance_targets=("cleanup_only",))) - - with pytest.raises(SystemExit, match="only selected repair targets"): - check_workflow.validate_check_options(_options(cleanup=True, maintenance_targets=("repair_only",))) - - def test_run_blob_store_check_reports_missing_orphaned_and_verified_states() -> None: config = _config() with patch("polylogue.storage.blob_integrity.scan_blob_integrity") as scan: @@ -147,7 +123,7 @@ def test_run_blob_store_check_returns_json_payload_without_emitting() -> None: assert payload == {"ok": True, "findings": []} -def test_schema_verification_and_maintenance_helpers_cover_runtime_paths() -> None: +def test_schema_verification_helpers_cover_runtime_paths() -> None: config = _config() options = _options( check_schemas=True, @@ -156,9 +132,7 @@ def test_schema_verification_and_maintenance_helpers_cover_runtime_paths() -> No schema_record_limit=10, schema_record_offset=2, schema_quarantine_malformed=True, - repair=True, ) - report = _report() schema_report = cast(SchemaVerificationReport, SimpleNamespace()) session_progress_callback = cast(Any, lambda: None) @@ -183,94 +157,32 @@ def test_schema_verification_and_maintenance_helpers_cover_runtime_paths() -> No parse_samples.assert_called_once_with("25") builtins_print.assert_called_once() - with patch( - "polylogue.cli.shared.check_workflow.make_session_insight_progress_callback", - return_value=session_progress_callback, - ): - assert ( - check_workflow._session_insight_progress_callback(options, ("session_insights",)) - is session_progress_callback - ) - assert check_workflow._session_insight_progress_callback(_options(repair=True, preview=True), ()) is None - assert check_workflow._session_insight_progress_callback(_options(repair=True, json_output=True), ()) is None - - with ( - patch( - "polylogue.cli.shared.check_workflow._resolve_selected_maintenance_targets", - return_value=("session_insights",), - ), - patch("polylogue.cli.shared.check_workflow._build_preview_counts", return_value={"session_insights": 2}), - ): - preview_inputs = check_workflow._maintenance_run_inputs(_options(repair=True, preview=True), report) - assert preview_inputs.selected_targets == ("session_insights",) - assert preview_inputs.preview_counts == {"session_insights": 2} - - result = CheckCommandResult(report=report) - inputs = check_workflow._MaintenanceRunInputs(selected_targets=("session_insights",), preview_counts={"x": 1}) - repair_result = cast(RepairResult, SimpleNamespace()) - with patch( - "polylogue.cli.shared.check_workflow.run_selected_maintenance", return_value=[repair_result] - ) as run_selected: - check_workflow._run_maintenance(config, result, _options(repair=True), inputs) - assert result.maintenance_targets == ("session_insights",) - assert result.maintenance_results == [repair_result] - assert run_selected.call_args.kwargs["targets"] == ("session_insights",) - - env = _env() - result.vacuum_result = VacuumResult(ok=True, detail="done") - with patch("polylogue.cli.shared.check_workflow.persist_maintenance_run") as persist_run: - check_workflow._persist_maintenance_run( - env, - report=report, - result=result, - options=_options(repair=True), - inputs=inputs, - ) - persist_run.assert_called_once() - -def test_run_check_workflow_covers_runtime_blob_vacuum_and_persist_paths() -> None: +def test_run_check_workflow_covers_runtime_and_blob_paths() -> None: env = _env() config = _config() object.__setattr__(env, "config", config) report = _report() runtime_report = ReadinessReport(checks=[ReadinessCheck("runtime", VerifyStatus.OK, summary="ok")]) options = _options( - repair=True, runtime=True, check_blob=True, - vacuum=True, json_output=True, ) - repair_result = cast(RepairResult, SimpleNamespace()) with ( patch("polylogue.cli.shared.check_workflow.load_effective_config", return_value=config), - patch("polylogue.cli.shared.check_workflow.get_readiness", return_value=report), + patch("polylogue.cli.shared.check_workflow.get_readiness", return_value=report) as get_readiness, patch("polylogue.cli.shared.check_workflow.run_runtime_readiness", return_value=runtime_report), patch("polylogue.cli.shared.check_workflow._run_blob_store_check") as run_blob_check, - patch( - "polylogue.cli.shared.check_workflow._maintenance_run_inputs", - return_value=check_workflow._MaintenanceRunInputs( - selected_targets=("session_insights",), preview_counts=None - ), - ), - patch("polylogue.cli.shared.check_workflow.run_selected_maintenance", return_value=[repair_result]), - patch("polylogue.cli.shared.check_workflow.make_session_insight_progress_callback", return_value="progress"), - patch( - "polylogue.cli.shared.check_workflow.vacuum_database", return_value=VacuumResult(ok=True, detail="vacuumed") - ), - patch("polylogue.cli.shared.check_workflow.persist_maintenance_run") as persist_run, ): result = check_workflow.run_check_workflow(env, options) assert result.report is report assert result.runtime_report is runtime_report - assert result.maintenance_results == [repair_result] - assert result.vacuum_result == VacuumResult(ok=True, detail="vacuumed") assert result.blob_report is run_blob_check.return_value run_blob_check.assert_called_once_with(config, full=False) - persist_run.assert_called_once() + get_readiness.assert_called_once_with(config, deep=False, probe_only=True) def test_run_check_workflow_includes_daemon_status_when_requested() -> None: diff --git a/tests/unit/cli/test_convergence_feedback.py b/tests/unit/cli/test_convergence_feedback.py index 41d3fa1635..04a1f7e75d 100644 --- a/tests/unit/cli/test_convergence_feedback.py +++ b/tests/unit/cli/test_convergence_feedback.py @@ -8,24 +8,8 @@ from polylogue.cli.convergence_feedback import convergence_warning_line -def test_convergence_warning_line_prefers_active_rebuild(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("polylogue.paths.archive_root", lambda: Path("/archive")) - monkeypatch.setattr( - "polylogue.storage.archive_readiness.active_rebuild_index_attempts", - lambda _ops_db: [{"parsed_raw_count": 12, "materialized_count": 3}], - ) - - warning = convergence_warning_line() - - assert warning == ( - "Archive is converging: 1 index rebuild attempt(s) active " - "(3 sessions materialized from 12 parsed raw rows); results may be partial." - ) - - def test_convergence_warning_line_reports_actionable_raw_debt(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("polylogue.paths.archive_root", lambda: Path("/archive")) - monkeypatch.setattr("polylogue.storage.archive_readiness.active_rebuild_index_attempts", lambda _ops_db: []) monkeypatch.setattr( "polylogue.storage.archive_readiness.raw_materialization_readiness_snapshot", lambda _root: { @@ -49,7 +33,6 @@ def test_convergence_warning_line_reports_actionable_raw_debt(monkeypatch: pytes def test_convergence_warning_line_omits_classified_raw_gaps(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("polylogue.paths.archive_root", lambda: Path("/archive")) - monkeypatch.setattr("polylogue.storage.archive_readiness.active_rebuild_index_attempts", lambda _ops_db: []) monkeypatch.setattr( "polylogue.storage.archive_readiness.raw_materialization_readiness_snapshot", lambda _root: { @@ -70,7 +53,6 @@ def test_convergence_warning_line_omits_classified_raw_gaps(monkeypatch: pytest. def test_convergence_warning_line_reports_unclassified_join_gaps(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("polylogue.paths.archive_root", lambda: Path("/archive")) - monkeypatch.setattr("polylogue.storage.archive_readiness.active_rebuild_index_attempts", lambda _ops_db: []) monkeypatch.setattr( "polylogue.storage.archive_readiness.raw_materialization_readiness_snapshot", lambda _root: { @@ -102,18 +84,15 @@ def test_convergence_warning_line_reports_undetermined_when_probe_raises( ) -> None: """An unanswerable readiness check must not render as a healthy archive. - This warning is what tells the operator that query results may be partial - while a rebuild is in flight or raw materialization is behind. Swallowing the - failure and returning ``None`` made every such error indistinguishable from - "checked, and results are complete", so partial results were presented as - complete with nothing to indicate the check never ran. + ``None`` means "checked, and results are complete". A probe failure that + returned ``None`` would present partial results as complete. """ - def _raise(_ops_db: Path) -> list[dict[str, object]]: - raise sqlite3.OperationalError("no such table: index_rebuild_attempts") + def _raise(_root: Path) -> dict[str, object]: + raise sqlite3.OperationalError("no such table: raw_materialization_status") monkeypatch.setattr("polylogue.paths.archive_root", lambda: Path("/archive")) - monkeypatch.setattr("polylogue.storage.archive_readiness.active_rebuild_index_attempts", _raise) + monkeypatch.setattr("polylogue.storage.archive_readiness.raw_materialization_readiness_snapshot", _raise) warning = convergence_warning_line() diff --git a/tests/unit/cli/test_paths.py b/tests/unit/cli/test_paths.py index 1813ad72d4..ca0b5c8233 100644 --- a/tests/unit/cli/test_paths.py +++ b/tests/unit/cli/test_paths.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -import sqlite3 -import time from pathlib import Path import pytest @@ -16,11 +14,6 @@ from polylogue.daemon.provenance import _build_raw_preview from polylogue.paths import blob_store_root from polylogue.storage.blob_store import get_blob_store -from polylogue.storage.sqlite.archive_tiers.ops_write import record_ingest_attempt - -pytestmark = pytest.mark.uses_real_clock( - "Rebuild-readiness path test records a live ingest heartbeat timestamp; production readiness compares that heartbeat against the host clock by design." -) _ARCHIVE_TIERS = ("source.db", "index.db", "embeddings.db", "ops.db", "user.db") @@ -115,85 +108,6 @@ def test_archive_override_unifies_blob_write_read_maintenance_report_and_reset( assert not expected_blob_root.exists() -def test_paths_json_reports_running_rebuild_as_not_ready( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, -) -> None: - """A current-schema index file is not usable while rebuild-index is running.""" - ops_db = cli_workspace["archive_root"] / "ops.db" - with sqlite3.connect(ops_db) as conn: - now_ms = int(time.time() * 1000) - record_ingest_attempt( - conn, - attempt_id="rebuild-active", - source_path=str(cli_workspace["archive_root"] / "source.db"), - status="running", - phase="rebuild-index", - started_at_ms=now_ms - 1_000, - heartbeat_at_ms=now_ms, - storage_route="maintenance", - ) - - result = cli_runner.invoke( - paths_command, - ["--format", "json"], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["archive_schema_ready"] is True - assert payload["archive_layout_ready"] is True - assert payload["archive_ready"] is False - assert payload["archive_materialization_ready"] is False - assert payload["active_rebuild_index_attempts"] == [ - { - "attempt_id": "rebuild-active", - "phase": "rebuild-index", - "started_at_ms": now_ms - 1_000, - "heartbeat_at_ms": now_ms, - "parsed_raw_count": 0, - "materialized_count": 0, - } - ] - assert isinstance(payload["active_archive_root"], str) - assert isinstance(payload["database_path"], str) - assert isinstance(payload["config_file_path"], str) - assert isinstance(payload["blob_store_root"], str) - - -def test_paths_json_ignores_stale_running_rebuild_as_not_active( - cli_workspace: dict[str, Path], - cli_runner: CliRunner, -) -> None: - """A stale running rebuild row is telemetry, not active materialization work.""" - ops_db = cli_workspace["archive_root"] / "ops.db" - with sqlite3.connect(ops_db) as conn: - record_ingest_attempt( - conn, - attempt_id="rebuild-stale", - source_path=str(cli_workspace["archive_root"] / "source.db"), - status="running", - phase="rebuild-index", - started_at_ms=1_700_000_000_000, - heartbeat_at_ms=1_700_000_001_000, - storage_route="maintenance", - ) - - result = cli_runner.invoke( - paths_command, - ["--format", "json"], - catch_exceptions=False, - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["archive_schema_ready"] is True - assert payload["archive_ready"] is True - assert payload["archive_materialization_ready"] is True - assert payload["active_rebuild_index_attempts"] == [] - - def test_paths_json_reports_raw_materialization_debt_as_not_ready( cli_workspace: dict[str, Path], cli_runner: CliRunner, diff --git a/tests/unit/core/test_config_resolution_regression.py b/tests/unit/core/test_config_resolution_regression.py index b0ee6ce334..6ee5410ba8 100644 --- a/tests/unit/core/test_config_resolution_regression.py +++ b/tests/unit/core/test_config_resolution_regression.py @@ -382,13 +382,13 @@ def test_raw_authority_commit_batch_size_toml_only_reaches_repair_resolution( ) -> None: """Reverted-mutation witness: restore ``raw = os.environ.get("POLYLOGUE_RAW_AUTHORITY_COMMIT_BATCH_SIZE")`` - in ``polylogue/storage/repair.py::_resolve_raw_authority_commit_batch_size`` + in ``polylogue/storage/raw_convergence.py::_resolve_raw_authority_commit_batch_size`` -- the test then fails because no environment variable is set (TOML-only configuration) and resolution falls back to the module's hardcoded ``RAW_MATERIALIZATION_COMMIT_BATCH_SIZE`` default instead of the configured value. """ - from polylogue.storage.repair import _resolve_raw_authority_commit_batch_size + from polylogue.storage.raw_convergence import _resolve_raw_authority_commit_batch_size _disable_site(monkeypatch) monkeypatch.delenv("POLYLOGUE_RAW_AUTHORITY_COMMIT_BATCH_SIZE", raising=False) diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index efb48c0be1..ba1226e8ab 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -31,7 +31,6 @@ from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database from polylogue.storage.sqlite.archive_tiers.embeddings import EMBEDDINGS_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.index import INDEX_SCHEMA_VERSION -from polylogue.storage.sqlite.archive_tiers.ops_write import record_ingest_attempt from polylogue.storage.sqlite.archive_tiers.source import SOURCE_SCHEMA_VERSION from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.user import USER_SCHEMA_VERSION @@ -198,55 +197,6 @@ def test_polylogued_status_json_reports_archive_storage(tmp_path: Path) -> None: } -def test_polylogued_status_json_reports_rebuild_index_not_ready(tmp_path: Path) -> None: - for filename, tier in ( - ("source.db", ArchiveTier.SOURCE), - ("index.db", ArchiveTier.INDEX), - ("embeddings.db", ArchiveTier.EMBEDDINGS), - ("user.db", ArchiveTier.USER), - ("ops.db", ArchiveTier.OPS), - ): - initialize_archive_database(tmp_path / filename, tier) - now_ms = 1_700_000_001_000 - with sqlite3.connect(tmp_path / "ops.db") as conn: - record_ingest_attempt( - conn, - attempt_id="rebuild-active", - source_path=str(tmp_path / "source.db"), - status="running", - phase="rebuild-index", - started_at_ms=now_ms - 1_000, - heartbeat_at_ms=now_ms, - storage_route="maintenance", - ) - - with ( - patch("polylogue.daemon.status.archive_root", return_value=tmp_path), - patch("polylogue.daemon.status._active_status_db_path", return_value=tmp_path / "index.db"), - patch("polylogue.daemon.status.default_sources", return_value=()), - patch("polylogue.storage.archive_readiness.time.time", return_value=now_ms / 1000), - ): - result = CliRunner().invoke(main, ["status", "--format", "json"]) - - assert result.exit_code == 0 - payload = loads(result.output) - assert isinstance(payload, dict) - storage = cast(dict[str, object], payload["archive_storage"]) - assert storage["archive_schema_ready"] is True - assert storage["archive_ready"] is False - assert storage["archive_materialization_ready"] is False - assert storage["active_rebuild_index_attempts"] == [ - { - "attempt_id": "rebuild-active", - "phase": "rebuild-index", - "started_at_ms": now_ms - 1_000, - "heartbeat_at_ms": now_ms, - "parsed_raw_count": 0, - "materialized_count": 0, - } - ] - - def test_polylogued_status_json_reports_schema_mismatch_not_ready(tmp_path: Path) -> None: for filename, tier in ( ("source.db", ArchiveTier.SOURCE), @@ -287,7 +237,7 @@ def test_polylogued_status_json_reports_schema_mismatch_not_ready(tmp_path: Path components = cast(dict[str, dict[str, object]], components_raw) archive_component = components["archive_storage"] assert archive_component["state"] == "blocked" - assert archive_component["repair_hint"] == "polylogue ops maintenance rebuild-index" + assert archive_component["repair_hint"] == "polylogued run" def test_polylogued_status_plain_reports_archive_storage(tmp_path: Path) -> None: @@ -539,7 +489,7 @@ def fake_restore_direct_blob_reference_debt( calls["restore_sample_size"] = sample_size return FakeRestoreResult() - def fake_repair_raw_materialization( + def fake_converge_raw_materialization( config: Config, *, dry_run: bool, @@ -580,7 +530,9 @@ def fake_converge(config: Config, *, limit: int) -> int: "polylogue.storage.blob_integrity.restore_direct_blob_reference_debt", fake_restore_direct_blob_reference_debt, ) - monkeypatch.setattr("polylogue.storage.repair.repair_raw_materialization", fake_repair_raw_materialization) + monkeypatch.setattr( + "polylogue.storage.raw_convergence.converge_raw_materialization", fake_converge_raw_materialization + ) monkeypatch.setattr( "polylogue.storage.raw_reconciler.recover_interrupted_raw_authority_frontier", fake_recover, @@ -656,12 +608,12 @@ def test_whale_writer_route_blocks_unproven_cursor_authority( lambda _root: f"{authority_state} cursor authority", ) - def fake_repair(*_args: object, **_kwargs: object) -> object: - mutations.append("repair_materialization") + def fake_converge(*_args: object, **_kwargs: object) -> object: + mutations.append("converge_materialization") (archive / "writer-mutated").write_text("unsafe", encoding="utf-8") return SimpleNamespace(success=True, repaired_count=1, detail="unexpected writer call") - monkeypatch.setattr("polylogue.maintenance.raw_authority.repair_materialization", fake_repair) + monkeypatch.setattr("polylogue.maintenance.raw_authority.converge_materialization", fake_converge) monkeypatch.setattr(daemon_cli, "_close_raw_materialization_fts", lambda _path, *, ops_db_path: None) monkeypatch.setattr(daemon_cli, "_emit_raw_materialization_pass", lambda _result: None) @@ -741,141 +693,6 @@ async def run_under_daemon_coordinator() -> int: assert apply_calls == [{"preview_census_id": "census-1", "selected_plan_ids": ("safe-1",)}] -def test_maybe_recommend_bulk_rebuild_silent_below_threshold(monkeypatch: pytest.MonkeyPatch) -> None: - """A backlog under both thresholds must not trigger the bulk-rebuild - recommendation: this exercises the real threshold predicate - (`_bulk_scale_raw_materialization_backlog`), not a stub -- removing the - predicate's comparisons (e.g. hardcoding it to always return True) makes - this test fail because the journal would then log unconditionally.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - monkeypatch.setattr(daemon_cli, "_last_bulk_rebuild_recommendation_monotonic", None) - counts = RawMaterializationCounts( - repaired_sessions=1, - candidate_count=daemon_cli._BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD, - pending_blob_bytes=daemon_cli._BULK_REBUILD_RECOMMENDATION_BYTES_THRESHOLD, - ) - with patch.object(daemon_cli.logger, "warning") as warning: - daemon_cli._maybe_recommend_bulk_rebuild(counts) - - warning.assert_not_called() - - -def test_maybe_recommend_bulk_rebuild_fires_on_candidate_count_threshold( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Exceeding the candidate-count threshold alone (bytes below threshold) - must trigger the loud journal recommendation naming the bulk rebuild - command. This exercises `_maybe_recommend_bulk_rebuild` -> - `_bulk_scale_raw_materialization_backlog` against production constants; - deleting either comparison in the predicate makes this test fail.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - monkeypatch.setattr(daemon_cli, "_last_bulk_rebuild_recommendation_monotonic", None) - monkeypatch.setattr("polylogue.daemon.cli.time.monotonic", lambda: 1_000.0) - counts = RawMaterializationCounts( - candidate_count=daemon_cli._BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD + 1, - pending_blob_bytes=0, - ) - with patch.object(daemon_cli.logger, "warning") as warning: - daemon_cli._maybe_recommend_bulk_rebuild(counts) - - warning.assert_called_once() - message, *args = warning.call_args.args - assert "rebuild-index" in message - assert "polylogue ops maintenance rebuild-index" in message - assert args[0] == counts.candidate_count - - -def test_maybe_recommend_bulk_rebuild_fires_on_byte_threshold(monkeypatch: pytest.MonkeyPatch) -> None: - """Exceeding the pending-bytes threshold alone (candidate count below - threshold) must also trigger the recommendation -- the two thresholds - are independent tripwires, not a combined one.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - monkeypatch.setattr(daemon_cli, "_last_bulk_rebuild_recommendation_monotonic", None) - monkeypatch.setattr("polylogue.daemon.cli.time.monotonic", lambda: 1_000.0) - counts = RawMaterializationCounts( - candidate_count=0, - pending_blob_bytes=daemon_cli._BULK_REBUILD_RECOMMENDATION_BYTES_THRESHOLD + 1, - ) - with patch.object(daemon_cli.logger, "warning") as warning: - daemon_cli._maybe_recommend_bulk_rebuild(counts) - - warning.assert_called_once() - - -def test_maybe_recommend_bulk_rebuild_is_rate_limited_to_once_per_hour( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Repeated passes over a still bulk-scale backlog must not re-log the - recommendation more than once per hour; the third call, past the - interval, must log again.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - monkeypatch.setattr(daemon_cli, "_last_bulk_rebuild_recommendation_monotonic", None) - clock = iter( - [ - 1_000.0, # first call: logs, records last=1000.0 - 1_000.0 + daemon_cli._BULK_REBUILD_RECOMMENDATION_MIN_INTERVAL_SECONDS - 1, # within interval: silent - 1_000.0 + daemon_cli._BULK_REBUILD_RECOMMENDATION_MIN_INTERVAL_SECONDS + 1, # past interval: logs again - ] - ) - monkeypatch.setattr("polylogue.daemon.cli.time.monotonic", lambda: next(clock)) - counts = RawMaterializationCounts( - candidate_count=daemon_cli._BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD + 1, - pending_blob_bytes=0, - ) - with patch.object(daemon_cli.logger, "warning") as warning: - daemon_cli._maybe_recommend_bulk_rebuild(counts) - daemon_cli._maybe_recommend_bulk_rebuild(counts) - daemon_cli._maybe_recommend_bulk_rebuild(counts) - - assert warning.call_count == 2 - - -def test_periodic_raw_materialization_convergence_recommends_bulk_rebuild_for_bulk_backlog( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The periodic conveyor loop must actually invoke the bulk-rebuild - recommendation check per pass, not merely define it unreachably.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - async def fake_run_sync(_actor: str, _func: object, *_args: object, **_kwargs: object) -> object: - return RawMaterializationCounts( - repaired_sessions=0, - executed_plans=0, - remaining_candidates=0, - candidate_count=daemon_cli._BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD + 1, - pending_blob_bytes=0, - ) - - async def fake_sleep(_seconds: float) -> None: - raise asyncio.CancelledError - - monkeypatch.setattr(daemon_cli, "_browser_capture_spool_has_pending_files", lambda: False) - monkeypatch.setattr( - daemon_cli, - "daemon_write_coordinator", - lambda: SimpleNamespace(run_sync=fake_run_sync), - ) - recommended: list[int] = [] - monkeypatch.setattr( - daemon_cli, - "_maybe_recommend_bulk_rebuild", - lambda counts: recommended.append(counts.candidate_count), - ) - with patch("asyncio.sleep", side_effect=fake_sleep), pytest.raises(asyncio.CancelledError): - asyncio.run(daemon_cli._periodic_raw_materialization_convergence()) - - assert recommended == [daemon_cli._BULK_REBUILD_RECOMMENDATION_CANDIDATE_THRESHOLD + 1] - - def test_raw_materialization_pass_emits_conserved_plan_receipt(monkeypatch: pytest.MonkeyPatch) -> None: from polylogue.daemon import cli as daemon_cli @@ -1065,7 +882,7 @@ def test_raw_materialization_closes_fts_on_cancellation( class FakeRestoreResult: restored_count = 0 - def cancel_repair(*_args: object, **_kwargs: object) -> object: + def cancel_converge(*_args: object, **_kwargs: object) -> object: raise asyncio.CancelledError monkeypatch.setattr("polylogue.paths.archive_root", lambda: archive) @@ -1075,7 +892,7 @@ def cancel_repair(*_args: object, **_kwargs: object) -> object: "polylogue.storage.blob_integrity.restore_direct_blob_reference_debt", lambda *_args, **_kwargs: FakeRestoreResult(), ) - monkeypatch.setattr("polylogue.storage.repair.repair_raw_materialization", cancel_repair) + monkeypatch.setattr("polylogue.storage.raw_convergence.converge_raw_materialization", cancel_converge) monkeypatch.setattr( daemon_cli, "_close_raw_materialization_fts", @@ -1156,7 +973,9 @@ def resolve_stale(_config: Config) -> int: monkeypatch.setattr("polylogue.maintenance.raw_authority.recover_interrupted_frontier", recover_frontier) monkeypatch.setattr("polylogue.maintenance.raw_authority.auto_resolve_stale_plan_blockers", resolve_stale) - monkeypatch.setattr("polylogue.maintenance.raw_authority.repair_materialization", lambda *_args, **_kwargs: result) + monkeypatch.setattr( + "polylogue.maintenance.raw_authority.converge_materialization", lambda *_args, **_kwargs: result + ) monkeypatch.setattr("polylogue.maintenance.raw_authority.materialization_generation_lease", fake_generation_lease) monkeypatch.setattr(daemon_cli, "_emit_raw_materialization_pass", lambda _result: None) @@ -1197,14 +1016,14 @@ def test_raw_materialization_outer_lease_refusal_preserves_typed_result( tmp_path: Path, whale: bool, ) -> None: - """Both daemon routes must emit the repair contract when pinning is refused.""" + """Both daemon routes must emit the convergence contract when pinning is refused.""" from polylogue.daemon import cli as daemon_cli from polylogue.storage.index_generation import ActiveWriterLease, RebuildLeaseUnavailableError - from polylogue.storage.repair import RepairResult + from polylogue.storage.raw_convergence import RawConvergenceResult archive = tmp_path / "archive" archive.mkdir() - emitted: list[RepairResult] = [] + emitted: list[RawConvergenceResult] = [] def refuse_outer_lease(_lease: ActiveWriterLease) -> None: raise RebuildLeaseUnavailableError("offline rebuild is active") @@ -1212,8 +1031,8 @@ def refuse_outer_lease(_lease: ActiveWriterLease) -> None: def reject_restore(*_args: object, **_kwargs: object) -> None: raise AssertionError("blob-reference restoration requires an acquired generation pin") - def reject_repair(*_args: object, **_kwargs: object) -> None: - raise AssertionError("repair must not run when the outer generation pin is refused") + def reject_converge(*_args: object, **_kwargs: object) -> None: + raise AssertionError("convergence must not run when the outer generation pin is refused") monkeypatch.setattr("polylogue.paths.archive_root", lambda: archive) monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") @@ -1230,7 +1049,7 @@ def reject_repair(*_args: object, **_kwargs: object) -> None: "polylogue.maintenance.raw_authority.auto_resolve_stale_plan_blockers", lambda _config: pytest.fail("stale-plan recovery requires an acquired generation pin"), ) - monkeypatch.setattr("polylogue.maintenance.raw_authority.repair_materialization", reject_repair) + monkeypatch.setattr("polylogue.maintenance.raw_authority.converge_materialization", reject_converge) monkeypatch.setattr(ActiveWriterLease, "acquire", refuse_outer_lease) monkeypatch.setattr(daemon_cli, "_emit_raw_materialization_pass", emitted.append) monkeypatch.setattr( @@ -1256,7 +1075,7 @@ def reject_repair(*_args: object, **_kwargs: object) -> None: assert len(emitted) == 1 result = emitted[0] - assert isinstance(result, RepairResult) + assert isinstance(result, RawConvergenceResult) assert result.name == "raw_materialization" assert result.success is False assert result.repaired_count == 0 @@ -4192,247 +4011,6 @@ async def fake_sleep(seconds: float) -> None: assert sleeps == 2 -def test_bulk_rebuild_routing_below_threshold_and_not_resumable_is_noop( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """A small, steady-state backlog with no bulk-rebuild already in flight - must never start one -- bulk routing is for bulk-scale backlogs only.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - def fail_run_pass(**_kwargs: object) -> object: - pytest.fail("must not drive a pass when below threshold and nothing is resumable") - - monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) - monkeypatch.setattr( - "polylogue.daemon.bulk_rebuild.has_resumable_daemon_bulk_rebuild_transaction", lambda _root: False - ) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass", fail_run_pass) - - counts = RawMaterializationCounts(candidate_count=3, pending_blob_bytes=0) - asyncio.run(daemon_cli._maybe_route_daemon_bulk_rebuild(counts)) - - -def test_bulk_rebuild_routing_resumable_transaction_drives_pass_even_below_threshold( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """An in-flight bulk-rebuild operation keeps being driven every tick even - once the instantaneous trickle backlog reading has dipped below the - bulk-scale threshold -- abandoning a partially-built generation would - waste every page already replayed into it.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - class FakeResolved: - daemon_parse_stage_workers = None - daemon_parse_stage_max_inflight_bytes = None - daemon_parse_stage_max_cached_tree_bytes = None - daemon_parse_stage_warm_timeout_seconds = None - - calls: list[dict[str, object]] = [] - - async def fake_run_pass(**kwargs: object) -> None: - calls.append(kwargs) - return None - - monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) - monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") - - async def resumable_transaction_in_flight() -> bool: - return True - - monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", resumable_transaction_in_flight) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass", fake_run_pass) - - counts = RawMaterializationCounts(candidate_count=3, pending_blob_bytes=0) - asyncio.run(daemon_cli._maybe_route_daemon_bulk_rebuild(counts)) - - assert len(calls) == 1 - called_config = cast(Config, calls[0]["config"]) - assert called_config.archive_root == tmp_path - - -def test_bulk_rebuild_routing_pass_failure_never_propagates( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """A failed bulk-rebuild pass must not crash the periodic convergence - loop it is called from -- the next tick simply tries again.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - async def fail_run_pass(**_kwargs: object) -> object: - raise RuntimeError("simulated bulk-rebuild pass failure") - - monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) - monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") - - async def resumable_transaction_in_flight() -> bool: - return True - - monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", resumable_transaction_in_flight) - monkeypatch.setattr("polylogue.daemon.bulk_rebuild.run_daemon_bulk_rebuild_pass", fail_run_pass) - - counts = RawMaterializationCounts(candidate_count=3, pending_blob_bytes=0) - asyncio.run(daemon_cli._maybe_route_daemon_bulk_rebuild(counts)) # must not raise - - -def test_daemon_bulk_rebuild_transaction_in_flight_delegates( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - """Flag on: delegates straight to ``has_resumable_daemon_bulk_rebuild_transaction`` - against the configured archive root.""" - from polylogue.daemon import cli as daemon_cli - - seen_roots: list[Path] = [] - validated_receipts: list[tuple[Path, Path]] = [] - receipt_path = tmp_path / "schema-inference-receipt.json" - - def fake_has_resumable(root: Path) -> bool: - seen_roots.append(root) - return True - - def validate_receipt(root: Path, receipt: Path) -> dict[str, object]: - validated_receipts.append((root, receipt)) - return {} - - monkeypatch.setattr("polylogue.paths.archive_root", lambda: tmp_path) - monkeypatch.setattr( - "polylogue.maintenance.schema_inference_gate.resolve_schema_inference_receipt_reference", - lambda _root: receipt_path, - ) - monkeypatch.setattr( - "polylogue.maintenance.schema_inference_gate.validate_schema_inference_receipt", validate_receipt - ) - monkeypatch.setattr( - "polylogue.daemon.bulk_rebuild.has_resumable_daemon_bulk_rebuild_transaction", fake_has_resumable - ) - - assert asyncio.run(daemon_cli._daemon_bulk_rebuild_transaction_in_flight()) is True - assert seen_roots == [tmp_path] - assert validated_receipts == [(tmp_path, receipt_path)] - - -def test_periodic_raw_materialization_convergence_suppresses_trickle_while_bulk_rebuild_in_flight( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """polylogue-gd6v residual: the trickle census/drain pass must stand - down for the tick while the daemon's own bulk-rebuild transaction is in - flight, instead of both mechanisms converging on the same raw backlog - every tick (double parse/replay work + needless writer-hold contention). - """ - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - async def fake_in_flight() -> bool: - return True - - routed: list[RawMaterializationCounts] = [] - - async def fake_route(counts: RawMaterializationCounts) -> bool: - routed.append(counts) - return True - - async def fail_run_sync(actor: str, _func: object, *_args: object, **_kwargs: object) -> object: - pytest.fail(f"trickle drain must not run while a bulk-rebuild transaction is in flight (actor={actor})") - - async def fake_sleep(_seconds: float) -> None: - raise asyncio.CancelledError - - monkeypatch.setattr(daemon_cli, "_browser_capture_spool_has_pending_files", lambda: False) - monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", fake_in_flight) - monkeypatch.setattr(daemon_cli, "_maybe_route_daemon_bulk_rebuild", fake_route) - monkeypatch.setattr(daemon_cli, "daemon_write_coordinator", lambda: SimpleNamespace(run_sync=fail_run_sync)) - - with patch("asyncio.sleep", side_effect=fake_sleep), pytest.raises(asyncio.CancelledError): - asyncio.run(daemon_cli._periodic_raw_materialization_convergence()) - - assert len(routed) == 1 - assert routed[0].candidate_count == 0 # a placeholder counts object -- has_resumable alone gates routing - - -def test_periodic_raw_materialization_convergence_falls_back_to_outer_interval_on_bulk_rebuild_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A swallowed bulk-rebuild pass failure during suppression must not - turn into a tight 1s retry storm -- it falls back to the same slower - outer interval a trickle pass failure already falls back to.""" - from polylogue.daemon import cli as daemon_cli - - async def fake_in_flight() -> bool: - return True - - async def fake_route_fails(_counts: object) -> bool: - return False # mirrors _maybe_route_daemon_bulk_rebuild's own swallowed-failure return - - sleeps: list[float] = [] - - async def fake_sleep(seconds: float) -> None: - sleeps.append(seconds) - raise asyncio.CancelledError - - async def fail_run_sync(actor: str, _func: object, *_args: object, **_kwargs: object) -> object: - pytest.fail(f"trickle drain must not run while a bulk-rebuild transaction is in flight (actor={actor})") - - monkeypatch.setattr(daemon_cli, "_browser_capture_spool_has_pending_files", lambda: False) - monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", fake_in_flight) - monkeypatch.setattr(daemon_cli, "_maybe_route_daemon_bulk_rebuild", fake_route_fails) - monkeypatch.setattr(daemon_cli, "daemon_write_coordinator", lambda: SimpleNamespace(run_sync=fail_run_sync)) - - with patch("asyncio.sleep", side_effect=fake_sleep), pytest.raises(asyncio.CancelledError): - asyncio.run(daemon_cli._periodic_raw_materialization_convergence()) - - assert sleeps == [daemon_cli._RAW_MATERIALIZATION_CONVERGENCE_INTERVAL_SECONDS] - - -def test_periodic_raw_materialization_convergence_resumes_trickle_once_bulk_rebuild_no_longer_in_flight( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Once ``has_resumable_daemon_bulk_rebuild_transaction`` flips false - (promoted or abandoned), the very next tick resumes the ordinary - trickle census/drain pass automatically -- no operator action, no wait - for the outer interval.""" - from polylogue.daemon import cli as daemon_cli - from polylogue.maintenance.raw_authority import RawMaterializationCounts - - in_flight_sequence = iter([True, False]) - - async def fake_in_flight() -> bool: - return next(in_flight_sequence) - - routed: list[object] = [] - - async def fake_route(counts: object) -> bool: - routed.append(counts) - return True - - trickle_calls: list[str] = [] - - async def fake_run_sync(actor: str, _func: object, *_args: object, **_kwargs: object) -> object: - trickle_calls.append(actor) - return RawMaterializationCounts(remaining_candidates=0) - - async def fake_sleep(seconds: float) -> None: - if seconds == daemon_cli._RAW_MATERIALIZATION_CONVERGENCE_INTERVAL_SECONDS: - raise asyncio.CancelledError - # burst-pause sleeps between suppressed passes: no-op, let the tick continue. - - monkeypatch.setattr(daemon_cli, "_browser_capture_spool_has_pending_files", lambda: False) - monkeypatch.setattr(daemon_cli, "_daemon_bulk_rebuild_transaction_in_flight", fake_in_flight) - monkeypatch.setattr(daemon_cli, "_maybe_route_daemon_bulk_rebuild", fake_route) - monkeypatch.setattr(daemon_cli, "_maybe_recommend_bulk_rebuild", lambda _counts: None) - monkeypatch.setattr(daemon_cli, "daemon_write_coordinator", lambda: SimpleNamespace(run_sync=fake_run_sync)) - - with patch("asyncio.sleep", side_effect=fake_sleep), pytest.raises(asyncio.CancelledError): - asyncio.run(daemon_cli._periodic_raw_materialization_convergence()) - - assert trickle_calls == ["maintenance.raw_materialization"] - assert len(routed) >= 1 # the suppression branch drove at least one bulk pass first - - # polylogue-t93b: the daemon's whale-pass escalation tier. A component # permanently resource-blocked at the ordinary fast-path envelope must not # stay blocked forever -- the periodic conveyor schedules a dedicated, @@ -4926,7 +4504,7 @@ def test_whale_cancellation_after_admission_records_continuation_then_real_compl started = threading.Event() release = threading.Event() - def blocked_repair(**_kwargs: object) -> object: + def blocked_converge(**_kwargs: object) -> object: started.set() release.wait(timeout=5) return SimpleNamespace( @@ -4941,7 +4519,7 @@ def blocked_repair(**_kwargs: object) -> object: }, ) - monkeypatch.setattr(daemon_cli, "_run_raw_materialization_whale_pass_once", blocked_repair) + monkeypatch.setattr(daemon_cli, "_run_raw_materialization_whale_pass_once", blocked_converge) coordinator = DaemonWriteCoordinator() monkeypatch.setattr(daemon_cli, "daemon_write_coordinator", lambda: coordinator) real_emit = daemon_events.emit_daemon_event @@ -5000,7 +4578,7 @@ async def scenario() -> None: assert any(record["idempotency_key"].endswith(":terminal") for record in pending) -def test_whale_callback_runs_real_coordinator_product_repair_and_census( +def test_whale_callback_runs_real_coordinator_product_convergence_and_census( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: @@ -5020,9 +4598,9 @@ def test_whale_callback_runs_real_coordinator_product_repair_and_census( ) conn.commit() from polylogue.config import Config - from polylogue.storage import repair as repair_mod + from polylogue.storage import raw_convergence as raw_convergence_mod - blocked = repair_mod.repair_raw_materialization( + blocked = raw_convergence_mod.converge_raw_materialization( Config(archive_root=tmp_path, render_root=tmp_path / "render", sources=[]), raw_artifact_id=raw_ids[0], max_payload_bytes=ordinary_limit, diff --git a/tests/unit/daemon/test_daemon_http_contracts.py b/tests/unit/daemon/test_daemon_http_contracts.py index 5d7bf348b2..a964a5b0ad 100644 --- a/tests/unit/daemon/test_daemon_http_contracts.py +++ b/tests/unit/daemon/test_daemon_http_contracts.py @@ -39,7 +39,6 @@ from http import HTTPStatus from io import BytesIO from pathlib import Path -from types import SimpleNamespace from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock @@ -193,48 +192,6 @@ def _archive_state_hash(archive_root: Path) -> str: return h.hexdigest() -def test_rebuild_index_schema_currency_conflict_preserves_preflight_diagnostic( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """The actual maintenance route returns the shared diagnostic, not a 500.""" - from polylogue.storage.archive_readiness import probe_archive_tier - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database - from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - from polylogue.storage.sqlite.migration_runner import DURABLE_MIGRATION_TIERS - - root = tmp_path / "archive" - root.mkdir() - for tier in sorted(DURABLE_MIGRATION_TIERS, key=lambda item: item.value): - initialize_archive_database(root / f"{tier.value}.db", tier) - source_probe = probe_archive_tier(ArchiveTier.SOURCE, root / "source.db") - with sqlite3.connect(root / "source.db") as conn: - conn.execute(f"PRAGMA user_version = {source_probe.expected_user_version + 1}") - monkeypatch.setattr("polylogue.paths.archive_root", lambda: root) - - handler = _make_handler("POST", "/api/maintenance/rebuild-index", body=b"{}") - handler.server.write_bridge = SimpleNamespace( # type: ignore[assignment] - run_sync_with_timeout=lambda _actor, _timeout, operation, request: operation(request) - ) - send_error, send_json = _capture_responses(handler) - - handler._handle_rebuild_index() - - send_error.assert_not_called() - status, payload = send_json.call_args.args - assert status == HTTPStatus.CONFLICT - assert payload["kind"] == "rebuild-schema-currency" - assert payload["status"] == "blocked" - assert payload["blocking_tiers"] == [ - { - "tier": "source", - "path": str(root / "source.db"), - "actual_user_version": source_probe.expected_user_version + 1, - "expected_user_version": source_probe.expected_user_version, - "status": "mismatch", - } - ] - - def test_cli_query_post_forwards_root_request_to_daemon_compiler() -> None: """The UDS-only envelope carries raw root flags, not a client-built SQL query.""" diff --git a/tests/unit/daemon/test_daemon_http_security.py b/tests/unit/daemon/test_daemon_http_security.py index f9a1cd3eed..e954f7effb 100644 --- a/tests/unit/daemon/test_daemon_http_security.py +++ b/tests/unit/daemon/test_daemon_http_security.py @@ -281,8 +281,6 @@ def test_valid_token_no_origin_passes_gates(self, path: str) -> None: with ( patch.object(handler, "_handle_reset"), patch.object(handler, "_handle_ingest"), - patch.object(handler, "_handle_maintenance_plan"), - patch.object(handler, "_handle_maintenance_run"), patch("polylogue.daemon.user_state_http.dispatch_post", return_value=True), ): handler.do_POST() @@ -305,8 +303,6 @@ def test_valid_token_same_origin_passes_gates(self, path: str) -> None: with ( patch.object(handler, "_handle_reset"), patch.object(handler, "_handle_ingest"), - patch.object(handler, "_handle_maintenance_plan"), - patch.object(handler, "_handle_maintenance_run"), patch("polylogue.daemon.user_state_http.dispatch_post", return_value=True), ): handler.do_POST() diff --git a/tests/unit/daemon/test_health_check_paths.py b/tests/unit/daemon/test_health_check_paths.py index 8bc8d575f6..699b429c51 100644 --- a/tests/unit/daemon/test_health_check_paths.py +++ b/tests/unit/daemon/test_health_check_paths.py @@ -522,7 +522,6 @@ def test_raw_failures_marks_deferred_work_retryable_not_unexplained( "parse_failures": 4, "validation_failures": 0, "quarantined": 4, - "maintenance_failures": 0, "deferred_failures": 4, "terminal_rejections": 0, "unexplained_failures": 0, @@ -550,7 +549,6 @@ def test_raw_failures_warning_names_deferred_and_terminal_states( "parse_failures": 5, "validation_failures": 0, "quarantined": 5, - "maintenance_failures": 0, "deferred_failures": 3, "terminal_rejections": 2, "unexplained_failures": 0, diff --git a/tests/unit/daemon/test_http_write_coordination.py b/tests/unit/daemon/test_http_write_coordination.py index 88e1105714..87dddca764 100644 --- a/tests/unit/daemon/test_http_write_coordination.py +++ b/tests/unit/daemon/test_http_write_coordination.py @@ -21,7 +21,6 @@ from polylogue.daemon.http import ( _CLI_DELETE_SELECTION_MAX_BYTES, - _REBUILD_INDEX_WRITE_TIMEOUT_S, DaemonAPIHandler, DaemonAPIHTTPServer, ) @@ -70,7 +69,6 @@ def allow_host(*, credential_request: bool = False) -> bool: [ (["api", "reset"], "_handle_reset", "http.reset"), (["api", "ingest"], "_handle_ingest", "http.ingest"), - (["api", "maintenance", "run"], "_handle_maintenance_run", "http.maintenance.run"), ], ) def test_authenticated_write_route_holds_gate_around_handler(path: list[str], handler_name: str, actor: str) -> None: @@ -617,69 +615,6 @@ def dispatch_delete(*_args: object) -> bool: ] -class _RecordingRebuildBridge(_RecordingBridge): - """Adds ``run_sync_with_timeout`` so real ``_handle_rebuild_index`` can run. - - polylogue-ogn1: rebuild-index uses ``run_sync_with_timeout`` (not - ``run_sync``) so a long rebuild pass isn't killed by the bridge's much - shorter default request timeout -- see ``DaemonAPIHandler._handle_rebuild_index`` - and ``DaemonWriteThreadBridge.run_sync_with_timeout``. - """ - - def run_sync_with_timeout( - self, actor: str, timeout: float | None, function: Callable[..., object], *args: object - ) -> object: - self.timeline.append(f"run_sync_with_timeout:{actor}:{timeout}") - return function(*args) - - -def test_rebuild_index_route_uses_the_bridge_run_sync_with_timeout_writer_path(monkeypatch, tmp_path) -> None: # type: ignore[no-untyped-def] - """Drive the request through the real production dispatch, not a stand-in. - - The previous version of this test replaced ``_handle_rebuild_index`` - wholesale with a body that itself called ``bridge.run_sync`` -- it only - proved the test's own stand-in called ``run_sync``, never that the real - production handler does anything of the kind (polylogue-ogn1 finding - #10). This exercises the real ``_do_post_impl`` route dispatch and the - real ``_handle_rebuild_index`` implementation end to end, with only the - typed rebuild service itself stubbed out. - """ - import json - from io import BytesIO - - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt - - monkeypatch.setenv("POLYLOGUE_ARCHIVE_ROOT", str(tmp_path)) - timeline: list[str] = [] - handler = _handler(["api", "maintenance", "rebuild-index"], timeline) - handler.server.write_bridge = _RecordingRebuildBridge(timeline) # type: ignore[assignment] - body = json.dumps({"promote": False, "raw_ids": ["raw-1"]}).encode("utf-8") - handler.headers = {"Content-Length": str(len(body))} # type: ignore[assignment] - handler.rfile = BytesIO(body) - - receipt = RebuildIndexReceipt( - archive_root=str(tmp_path), - raw_session_count=1, - selected_raw_count=1, - skipped_by_blob_limit_count=0, - status="replayed", - materialized=True, - materialization={}, - generation={"generation_id": "candidate-1", "active": False}, - readiness={"checked": True, "blocked_surface_count": 0}, - replay={"classified_full_count": 1, "replayed_logical_source_count": 1, "quarantined_raw_count": 0}, - ) - with patch("polylogue.maintenance.rebuild_index.rebuild_index_from_source_sync", return_value=receipt) as rebuild: - with patch.object(handler, "_send_json") as send_json: - handler._do_post_impl() - - assert timeline == [f"run_sync_with_timeout:http.maintenance.rebuild-index:{_REBUILD_INDEX_WRITE_TIMEOUT_S}"] - request = rebuild.call_args.args[0] - assert request.raw_ids == ("raw-1",) - assert request.promote is False - assert send_json.call_args.args == (HTTPStatus.OK, receipt.to_dict()) - - def test_standalone_http_server_owns_and_idempotently_closes_writer_runtime() -> None: server = DaemonAPIHTTPServer(("127.0.0.1", 0), DaemonAPIHandler) runtime = server._owned_write_runtime diff --git a/tests/unit/daemon/test_metrics_endpoint.py b/tests/unit/daemon/test_metrics_endpoint.py index 4ca4cbfde8..0ff4470d84 100644 --- a/tests/unit/daemon/test_metrics_endpoint.py +++ b/tests/unit/daemon/test_metrics_endpoint.py @@ -22,7 +22,6 @@ import re import sqlite3 import threading -import time from concurrent.futures import ThreadPoolExecutor from email.message import Message from http import HTTPStatus @@ -171,8 +170,6 @@ def test_build_info_exposes_full_revision_and_dirty_labels(self, tmp_path: Path) assert len(labels["revision"]) == 40 def test_archive_storage_metrics_report_archive_file_sets(self, tmp_path: Path) -> None: - from polylogue.storage.sqlite.archive_tiers.ops_write import record_ingest_attempt - for spec in ARCHIVE_TIER_SPECS.values(): if spec.tier is not ArchiveTier.EMBEDDINGS: initialize_archive_database(tmp_path / spec.filename, spec.tier) @@ -203,25 +200,6 @@ def test_archive_storage_metrics_report_archive_file_sets(self, tmp_path: Path) assert 'polylogue_fts_trigger_present{trigger="messages_fts_ad"} 1' in body assert 'polylogue_fts_trigger_present{trigger="messages_fts_au"} 1' in body - with sqlite3.connect(tmp_path / "ops.db") as conn: - now_ms = int(time.time() * 1000) - record_ingest_attempt( - conn, - attempt_id="rebuild-active", - source_path=str(tmp_path / "source.db"), - status="running", - phase="rebuild-index", - started_at_ms=now_ms - 1_000, - heartbeat_at_ms=now_ms, - storage_route="maintenance", - ) - - rebuilding_body = format_metrics(tmp_path / "index.db") - - assert 'polylogue_archive_storage_ready{state="materialized"} 0' in rebuilding_body - assert "polylogue_archive_rebuild_index_attempts 1" in rebuilding_body - assert "polylogue_archive_ready 0" in rebuilding_body - def test_db_space_metrics_report_wal_and_planner_stats(self, tmp_path: Path) -> None: index_db = tmp_path / "index.db" initialize_archive_database(index_db, ArchiveTier.INDEX) diff --git a/tests/unit/daemon/test_parse_prefetch.py b/tests/unit/daemon/test_parse_prefetch.py index c2636caad3..7302ebec63 100644 --- a/tests/unit/daemon/test_parse_prefetch.py +++ b/tests/unit/daemon/test_parse_prefetch.py @@ -4,7 +4,7 @@ * ``DaemonParseStage.warm`` -- the actual off-writer-hold pre-parse entry point the daemon conveyor calls. -* ``polylogue.storage.repair.raw_materialization_pending_census_raw_ids`` / +* ``polylogue.storage.raw_convergence.raw_materialization_pending_census_raw_ids`` / ``raw_materialization_readonly_descriptors`` -- the read-only candidate and descriptor lookups ``warm`` uses. * ``polylogue.sources.revision_backfill.census_parse_worker`` -- the same @@ -462,7 +462,7 @@ def test_warm_parses_indexed_raw_with_missing_parser_receipt(tmp_path: Path) -> The raw is already materialized in ``index.db`` but its parser receipt is removed. ``DaemonParseStage.warm`` must still parse it outside the writer - hold, because ``repair_raw_materialization`` will census that same raw. + hold, because ``converge_raw_materialization`` will census that same raw. Replacing the preview's parser-census selector with the ordinary replay selector makes ``warmed`` zero and leaves the cache empty. """ diff --git a/tests/unit/daemon/test_raw_materialization_parse_stage_equivalence.py b/tests/unit/daemon/test_raw_materialization_parse_stage_equivalence.py index f184fd8b4a..7526cde94e 100644 --- a/tests/unit/daemon/test_raw_materialization_parse_stage_equivalence.py +++ b/tests/unit/daemon/test_raw_materialization_parse_stage_equivalence.py @@ -9,8 +9,8 @@ prefetch cache at all, produces byte-identical durable archive content. Production dependencies exercised: ``DaemonParseStage.warm`` (the actual -off-writer-hold pre-parse path) feeding ``polylogue.storage.repair. -repair_raw_materialization``'s ``prefetch_cache`` parameter (the actual +off-writer-hold pre-parse path) feeding ``polylogue.storage.raw_convergence. +converge_raw_materialization``'s ``prefetch_cache`` parameter (the actual production plumbing the daemon conveyor uses), not a reimplementation of either. """ @@ -25,7 +25,7 @@ from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.daemon.parse_prefetch import DaemonParseStage -from polylogue.storage.repair import repair_raw_materialization +from polylogue.storage.raw_convergence import converge_raw_materialization from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root @@ -119,9 +119,9 @@ def test_flag_on_prefetch_and_flag_off_produce_identical_archive_content(tmp_pat _seed_corpus(baseline_root) _seed_corpus(prefetch_root) - # Flag OFF: parse happens entirely inside repair_raw_materialization, + # Flag OFF: parse happens entirely inside converge_raw_materialization, # exactly as production behaves today. - baseline_result = repair_raw_materialization( + baseline_result = converge_raw_materialization( _config(baseline_root), dry_run=False, raw_artifact_limit=100, @@ -136,7 +136,7 @@ def test_flag_on_prefetch_and_flag_off_produce_identical_archive_content(tmp_pat try: warmed = stage.warm(_config(prefetch_root), limit=100, max_payload_bytes=10_000_000) assert warmed == 4 - prefetch_result = repair_raw_materialization( + prefetch_result = converge_raw_materialization( _config(prefetch_root), dry_run=False, raw_artifact_limit=100, diff --git a/tests/unit/daemon/test_route_contracts.py b/tests/unit/daemon/test_route_contracts.py index 18e6a6a764..7bccc9af2c 100644 --- a/tests/unit/daemon/test_route_contracts.py +++ b/tests/unit/daemon/test_route_contracts.py @@ -2,9 +2,7 @@ from __future__ import annotations -import json from http import HTTPStatus -from types import SimpleNamespace from unittest.mock import MagicMock import pytest @@ -165,62 +163,6 @@ def test_find_openapi_operation_carries_declaration_contract() -> None: } -def test_rebuild_index_handler_forwards_resumable_pass_options_through_writer_bridge() -> None: - """The live HTTP handler reaches the daemon bridge with the canonical request model. - - This fails if the handler drops any bounded-pass field before handing the - request to ``rebuild_index_from_source_sync``. - """ - from polylogue.maintenance.rebuild_index import RebuildIndexReceipt - - receipt = RebuildIndexReceipt( - archive_root="/archive", - raw_session_count=2, - selected_raw_count=1, - skipped_by_blob_limit_count=0, - status="deferred", - materialized=False, - materialization={}, - generation={"generation_id": "candidate"}, - readiness={}, - replay={"scheduled_raw_count": 1}, - transaction={"operation_id": "resume-1", "status": "deferred"}, - ) - # polylogue-ogn1: the handler waits up to `_REBUILD_INDEX_WRITE_TIMEOUT_S` - # via `run_sync_with_timeout` (a longer-lived variant of `run_sync`, - # sized for a bounded rebuild pass rather than an ordinary request-scoped - # write) instead of the bridge's shorter default-timeout `run_sync`. - bridge = SimpleNamespace(write_bridge=MagicMock()) - bridge.write_bridge.run_sync_with_timeout.return_value = receipt - handler = _make_handler( - "POST", - "/api/maintenance/rebuild-index", - body=json.dumps( - { - "raw_batch_size": 17, - "pass_byte_budget_mb": 12.5, - "pass_deadline_seconds": 45, - "promote": False, - } - ).encode(), - server=bridge, - ) - _send_error, send_json = capture_responses(handler) - - handler._handle_rebuild_index() - - actor, timeout, function, request = bridge.write_bridge.run_sync_with_timeout.call_args.args - assert actor == "http.maintenance.rebuild-index" - assert timeout == 600.0 - assert function.__name__ == "rebuild_index_from_source_sync" - assert request.operation_id is None - assert request.raw_batch_size == 17 - assert request.pass_byte_budget_mb == 12.5 - assert request.pass_deadline_seconds == 45.0 - assert request.promote is False - send_json.assert_called_once_with(HTTPStatus.OK, receipt.to_dict()) - - def test_stable_routes_have_explicit_auth_and_response_contracts() -> None: """Stable routes must declare the security posture and response shape.""" diff --git a/tests/unit/daemon/test_web_auth.py b/tests/unit/daemon/test_web_auth.py index ddef8de467..c2b06bb951 100644 --- a/tests/unit/daemon/test_web_auth.py +++ b/tests/unit/daemon/test_web_auth.py @@ -145,9 +145,6 @@ def test_bootstrap_rotates_http_only_cookie_and_authenticates_read_route() -> No [ ("/api/reset", "_handle_reset"), ("/api/ingest", "_handle_ingest"), - ("/api/maintenance/plan", "_handle_maintenance_plan"), - ("/api/maintenance/run", "_handle_maintenance_run"), - ("/api/maintenance/rebuild-index", "_handle_rebuild_index"), ], ) def test_web_credential_cannot_execute_archive_control_routes(path: str, handler_name: str) -> None: diff --git a/tests/unit/pipeline/test_differential_paths.py b/tests/unit/pipeline/test_differential_paths.py index 0b76660b11..bd4d4bb053 100644 --- a/tests/unit/pipeline/test_differential_paths.py +++ b/tests/unit/pipeline/test_differential_paths.py @@ -8,7 +8,6 @@ import io import json -from pathlib import Path import pytest @@ -113,59 +112,4 @@ def test_empty_lines_skipped_by_both(self) -> None: assert sample_malformed == 0 -# --------------------------------------------------------------------------- -# 2. Health debt vs repair counts -# --------------------------------------------------------------------------- - - -class TestHealthRepairConvergence: - """Health/doctor and repair must agree on debt counts when querying - the same database state.""" - - def test_empty_session_count_agrees(self: object, workspace_env: dict[str, Path]) -> None: - """``count_empty_sessions_sync`` only counts a message-less session as - debris when its raw artifact positively fails the live - ``classify_artifact`` pipeline (polylogue-ne6k) -- so this seeds a - real ``agent-*.meta.json`` sidecar raw artifact (the genuinely-phantom - shape) behind the empty session, not a bare session row.""" - import sqlite3 - - from polylogue.storage.blob_store import BlobStore - from polylogue.storage.repair import count_empty_sessions_sync - from tests.infra.archive_scenarios import open_index_db - from tests.infra.storage_records import SessionBuilder, db_setup - - db_path = db_setup(workspace_env) - # Seed one real session so the archive schema is bootstrapped. - SessionBuilder(db_path, "seed").provider("chatgpt").title("Seed").add_message(role="user", text="hi").save() - - archive_root = workspace_env["archive_root"] - blob_store = BlobStore(archive_root / "blob") - raw_id, blob_size = blob_store.write_from_bytes(b'{"agentType":"general-purpose"}') - with sqlite3.connect(archive_root / "source.db") as source_conn: - source_conn.execute( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, 'chatgpt-export', 'ext-empty', 'agent-empty.meta.json', 0, ?, ?, 1) - """, - (raw_id, bytes.fromhex(raw_id), blob_size), - ) - source_conn.commit() - - with open_index_db(db_path) as conn: - # A archive session row with no messages is the "empty session" - # shape; ``content_hash`` is a 32-byte BLOB by CHECK constraint. - conn.execute( - "INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) " - "VALUES ('ext-empty', 'chatgpt-export', ?, 'Empty', " - "X'0011223344556677889900112233445566778899001122334455667788990011')", - (raw_id,), - ) - conn.commit() - count = count_empty_sessions_sync(conn) - - assert count >= 1, "Should detect empty session whose raw artifact fails classification" - - # --------------------------------------------------------------------------- diff --git a/tests/unit/sources/test_live_batch_support.py b/tests/unit/sources/test_live_batch_support.py index fb5069c5a5..17337f3876 100644 --- a/tests/unit/sources/test_live_batch_support.py +++ b/tests/unit/sources/test_live_batch_support.py @@ -7220,7 +7220,7 @@ def session(native_id: str, *texts: str) -> ParsedSession: # polylogue-5iz4: this guard's refusal is transient/retry-eligible by # construction (a later pass over the same durable bytes can succeed # once sibling evidence resolves), but a plain RuntimeError leaves the - # retry-candidate query (storage/repair.py) nothing stable to match + # retry-candidate query (storage/raw_convergence.py) nothing stable to match # once the message text drifts -- exactly what happened to a real # production session that hit this guard under #2718's original # wording. @@ -7451,7 +7451,7 @@ def _parse_stream_payload_stub( """, (str(incident_recovery),), ).fetchone() == ("deferred_cas_frontier",) - from polylogue.storage.repair import _raw_materialization_retryable_missing_blob_error + from polylogue.storage.raw_convergence import _raw_materialization_retryable_missing_blob_error assert _raw_materialization_retryable_missing_blob_error(parse_error) is True assert _raw_materialization_retryable_missing_blob_error("RuntimeError: unrelated parser failure") is False @@ -7491,7 +7491,7 @@ def _parse_stream_payload_stub( # succeeds and reaches the index with a plausible message_count -- # note this exercises the live-watcher's own retry path # (``_ingest_full_paths_sync`` again), not - # ``storage/repair.py``'s offline ``repair_raw_materialization``: + # ``storage/raw_convergence.py``'s offline ``converge_raw_materialization``: # that offline path reprocesses every retained typed-'full' raw for # this logical_source_key on every pass (including ``current``'s own # cohort), which re-establishes an accepted head before ever reaching diff --git a/tests/unit/sources/test_source_snapshot.py b/tests/unit/sources/test_source_snapshot.py index 9dd53ec14c..9d16881436 100644 --- a/tests/unit/sources/test_source_snapshot.py +++ b/tests/unit/sources/test_source_snapshot.py @@ -9,8 +9,6 @@ import pytest -from polylogue.config import Config, Source -from polylogue.maintenance.rebuild_index import freeze_candidate_source_inputs, verify_frozen_candidate_source_inputs from polylogue.maintenance.source_manifest_continuity import SourceDeclaration, SourceRole from polylogue.sources import source_snapshot from polylogue.sources.source_snapshot import ( @@ -135,25 +133,6 @@ def record_directory_fsync(path: Path) -> None: assert destination in fsyncs_after_marker -def test_verify_frozen_candidate_source_inputs_rejects_tampered_candidate(tmp_path: Path) -> None: - """Mutation: accepting a changed cut file would let candidate planning read unsealed bytes.""" - source = tmp_path / "configured-source" - source.mkdir() - (source / "session.jsonl").write_text("before\n", encoding="utf-8") - config = Config(archive_root=tmp_path, render_root=tmp_path / "render", sources=[Source("configured", source)]) - destination = tmp_path / "cut" - frozen = freeze_candidate_source_inputs( - config, - destination=destination, - request_id="tamper-check", - fallback_source_path=tmp_path / "source.db", - ) - (frozen.candidate_root / "configured-0" / "session.jsonl").write_text("tampered\n", encoding="utf-8") - - with pytest.raises(SourceMutationError, match="candidate snapshot mutated"): - verify_frozen_candidate_source_inputs(destination) - - def test_repeating_a_published_cut_reuses_its_manifest(tmp_path: Path) -> None: root = tmp_path / "source" root.mkdir() diff --git a/tests/unit/storage/test_archive_tiers_archive.py b/tests/unit/storage/test_archive_tiers_archive.py index 84529d5dcb..102ca75928 100644 --- a/tests/unit/storage/test_archive_tiers_archive.py +++ b/tests/unit/storage/test_archive_tiers_archive.py @@ -77,7 +77,7 @@ def test_read_open_rejects_stale_index_with_generation_and_lifecycle_action(tmp_ assert refusal.generation_id == "gen-stale-read" assert refusal.lifecycle_action == "rebuild_index" assert "gen-stale-read" in str(refusal) - assert "polylogue ops maintenance rebuild-index" in str(refusal) + assert "Reset the derived index and let `polylogued run` rebuild it from source." in str(refusal) def test_active_archive_root_refuses_replacement_after_acquiring_ownership( diff --git a/tests/unit/storage/test_browser_capture_origin_repair.py b/tests/unit/storage/test_browser_capture_origin_repair.py index c352ad4d1b..1a14c74331 100644 --- a/tests/unit/storage/test_browser_capture_origin_repair.py +++ b/tests/unit/storage/test_browser_capture_origin_repair.py @@ -18,6 +18,10 @@ from polylogue.sources.revision_backfill import _parse_one from polylogue.storage.blob_store import BlobStore from polylogue.storage.raw_authority import RawReplayPlanStatus, resolve_raw_authority_blocker +from polylogue.storage.raw_convergence import ( + inspect_browser_canonical_authority_conflicts, + inspect_browser_capture_origin_mismatches, +) from polylogue.storage.raw_reconciler import ( RawAuthorityActuator, RawAuthorityFrontierItem, @@ -26,11 +30,6 @@ apply_raw_authority_frontier, inspect_raw_authority_frontier, ) -from polylogue.storage.repair import ( - inspect_browser_canonical_authority_conflicts, - inspect_browser_capture_origin_mismatches, - record_browser_canonical_authority_conflict_blockers, -) from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from polylogue.storage.sqlite.archive_tiers.revision_application import ( @@ -1169,135 +1168,3 @@ def test_inspect_conflicts_rejects_duplicate_and_malformed_ids(tmp_path: Path) - inspect_browser_canonical_authority_conflicts(_config(tmp_path), ["not-a-raw-id"]) with pytest.raises(ValueError, match="1..100 entries"): inspect_browser_canonical_authority_conflicts(_config(tmp_path), []) - - -def test_record_conflict_blockers_persists_durable_candidate_assertions(tmp_path: Path) -> None: - from polylogue.storage.sqlite.archive_tiers.user_write import read_assertion_envelope - - raw_id = _seed_byte_proven_browser_head_without_native_id(tmp_path) - semantic_raw_id = _seed_semantic_canonical_head(tmp_path, raw_id) - with sqlite3.connect(tmp_path / "index.db") as index: - index.execute( - "UPDATE raw_revision_heads SET accepted_content_hash = x'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' " - "WHERE accepted_raw_id = ?", - (semantic_raw_id,), - ) - - report, assertion_ids = record_browser_canonical_authority_conflict_blockers(_config(tmp_path), [raw_id]) - - assert report.conflict_count == 1 - assert len(assertion_ids) == 1 - with closing(sqlite3.connect(tmp_path / "user.db")) as user_conn: - user_conn.row_factory = sqlite3.Row - envelope = read_assertion_envelope(user_conn, assertion_ids[0]) - assert envelope is not None - assert envelope.kind.value == "blocker" - # Automated writers can never self-promote (upsert_assertion's chokepoint, - # 37t.15): the row is a candidate awaiting explicit operator judgment even - # though this function requested status=candidate/visibility=private itself. - assert envelope.status.value == "candidate" - assert envelope.context_policy["inject"] is False - assert envelope.target_ref == "session:chatgpt-export:browser-origin-one" - assert isinstance(envelope.value, dict) - assert envelope.value["raw_id"] == raw_id - assert envelope.value["competing_raw_id"] == semantic_raw_id - - # Re-running over identical evidence is idempotent: same assertion id, not a - # duplicate row. - _report_again, assertion_ids_again = record_browser_canonical_authority_conflict_blockers( - _config(tmp_path), [raw_id] - ) - assert assertion_ids_again == assertion_ids - with closing(sqlite3.connect(tmp_path / "user.db")) as user_conn: - count = user_conn.execute( - "SELECT COUNT(*) FROM assertions WHERE kind = 'blocker' AND target_ref = ?", - ("session:chatgpt-export:browser-origin-one",), - ).fetchone()[0] - assert count == 1 - - -def test_record_conflict_blockers_never_clobbers_an_operator_judged_row(tmp_path: Path) -> None: - """A judged blocker (accepted/rejected/deferred/superseded) must survive a re-run. - - Mirrors ``upsert_pathology_findings_as_assertions``'s terminal-judgment - chokepoint (37t.15): once an operator has judged a candidate this - detector produced, a later automated re-run over the SAME evidence must - not resurrect or mutate the judged row's status/value -- only - ``upsert_assertion``'s own ON CONFLICT DO UPDATE would otherwise - overwrite the display fields (value/body_text/evidence_refs) on every - call, since only ``status`` itself is protected by that function's - chokepoint. Deleting the ``read_assertion_envelope``/``existing.status`` - guard in ``record_browser_canonical_authority_conflict_blockers`` makes - this test fail: the accepted row's value would silently be overwritten - back to the detector's regenerated evidence payload. - """ - from polylogue.storage.sqlite.archive_tiers.user_write import ( - judge_assertion_candidate, - read_assertion_envelope, - ) - - raw_id = _seed_byte_proven_browser_head_without_native_id(tmp_path) - semantic_raw_id = _seed_semantic_canonical_head(tmp_path, raw_id) - with sqlite3.connect(tmp_path / "index.db") as index: - index.execute( - "UPDATE raw_revision_heads SET accepted_content_hash = x'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' " - "WHERE accepted_raw_id = ?", - (semantic_raw_id,), - ) - - _report, assertion_ids = record_browser_canonical_authority_conflict_blockers(_config(tmp_path), [raw_id]) - assert len(assertion_ids) == 1 - assertion_ref = assertion_ids[0] - - with closing(sqlite3.connect(tmp_path / "user.db")) as user_conn: - judge_assertion_candidate( - user_conn, - candidate_ref=assertion_ref, - decision="accept", - reason="operator confirmed this conflict is real", - ) - user_conn.commit() - judged = read_assertion_envelope(user_conn, assertion_ref) - assert judged is not None - assert judged.status.value == "accepted" - judged_value = judged.value - judged_updated_at_ms = judged.updated_at_ms - - # Re-running the detector over the identical evidence must leave the - # judged row's status and value exactly as the operator left it. Passing - # a distinct now_ms is the genuine negative control: value/status are - # deterministically identical across both runs regardless of whether the - # guard fires (same evidence -> same computed value, and - # upsert_assertion's own terminal-judgment chokepoint separately protects - # status), so those two fields alone cannot distinguish "guard - # short-circuited before writing" from "guard removed, wrote anyway". - # updated_at_ms can: upsert_assertion's ON CONFLICT always sets - # updated_at_ms = excluded.updated_at_ms unconditionally, so if the guard - # in record_browser_canonical_authority_conflict_blockers were removed - # (or its read_assertion_envelope check bypassed), this second call would - # still invoke upsert_assertion for the judged row and updated_at_ms - # would move to the new now_ms -- an observable difference the guard - # must prevent entirely by never calling upsert_assertion at all. - _report_again, assertion_ids_again = record_browser_canonical_authority_conflict_blockers( - _config(tmp_path), [raw_id], now_ms=judged_updated_at_ms + 999_000 - ) - assert assertion_ids_again == assertion_ids - with closing(sqlite3.connect(tmp_path / "user.db")) as user_conn: - still_judged = read_assertion_envelope(user_conn, assertion_ref) - assert still_judged is not None - assert still_judged.status.value == "accepted" - assert still_judged.value == judged_value - assert still_judged.updated_at_ms == judged_updated_at_ms - # ``judge_assertion_candidate(decision="accept")`` legitimately leaves - # TWO ``blocker`` rows for this target: the original candidate - # (mutated in place to ``status=accepted`` by ``mark_assertion_status``, - # same assertion id as ``assertion_ref``) plus a separate promoted - # "resulting assertion" row that ``_promote_candidate_assertion`` - # inserts under a distinct id. The guard under test only has to leave - # the ORIGINAL judged row alone -- it must not grow a THIRD row (which - # would mean the re-run resurrected or duplicated the candidate). - count = user_conn.execute( - "SELECT COUNT(*) FROM assertions WHERE kind = 'blocker' AND target_ref = ?", - ("session:chatgpt-export:browser-origin-one",), - ).fetchone()[0] - assert count == 2 diff --git a/tests/unit/storage/test_duplicate_raw_identity_repair.py b/tests/unit/storage/test_duplicate_raw_identity_repair.py index 1f8105d9d3..7db9ab5839 100644 --- a/tests/unit/storage/test_duplicate_raw_identity_repair.py +++ b/tests/unit/storage/test_duplicate_raw_identity_repair.py @@ -645,10 +645,10 @@ def test_duplicate_alias_ineligible_proof_does_not_crash_the_whole_census( stale_raw_id, canonical_raw_id, heads = _seed_duplicate_raw_fanout(tmp_path) (session_a, key_a), (session_b, key_b) = heads - import polylogue.storage.repair as repair_module - from polylogue.storage.repair import DuplicateRawIdentityRepairItem + import polylogue.storage.raw_convergence as raw_convergence_module + from polylogue.storage.raw_convergence import DuplicateRawIdentityRepairItem - real_inspect = repair_module._inspect_duplicate_raw_identity + real_inspect = raw_convergence_module._inspect_duplicate_raw_identity def fake_inspect( conn: object, archive_root: object, stale: str, canonical: str, logical_source_key: str @@ -662,7 +662,7 @@ def fake_inspect( ) return real_inspect(conn, archive_root, stale, canonical, logical_source_key) # type: ignore[arg-type] - monkeypatch.setattr(repair_module, "_inspect_duplicate_raw_identity", fake_inspect) + monkeypatch.setattr(raw_convergence_module, "_inspect_duplicate_raw_identity", fake_inspect) # The regression: this must not raise, and must still classify session A # (the genuinely eligible sibling) correctly. diff --git a/tests/unit/storage/test_quarantine_repair_budget.py b/tests/unit/storage/test_quarantine_repair_budget.py index e8548f0ac8..da66061c57 100644 --- a/tests/unit/storage/test_quarantine_repair_budget.py +++ b/tests/unit/storage/test_quarantine_repair_budget.py @@ -15,7 +15,7 @@ import pytest -from polylogue.storage.repair import ( +from polylogue.storage.raw_convergence import ( _QUARANTINED_ACCEPTED_RAW_REPAIR_BLOB_LIMIT_BYTES, _QUARANTINED_ACCEPTED_RAW_REPAIR_TOTAL_BLOB_LIMIT_BYTES, _partition_quarantined_raw_repair_blob_budget, diff --git a/tests/unit/storage/test_quarantined_accepted_raw_repair.py b/tests/unit/storage/test_quarantined_accepted_raw_repair.py index 81997d3118..ff453f3c7d 100644 --- a/tests/unit/storage/test_quarantined_accepted_raw_repair.py +++ b/tests/unit/storage/test_quarantined_accepted_raw_repair.py @@ -16,13 +16,13 @@ from polylogue.sources.revision_backfill import _parse_one from polylogue.storage.blob_store import BlobStore from polylogue.storage.raw_authority import RAW_AUTHORITY_PARSER_FINGERPRINT +from polylogue.storage.raw_convergence import _stageable_quarantined_census_cohort, inspect_quarantined_accepted_raws from polylogue.storage.raw_reconciler import ( RawAuthorityActuator, RawAuthorityFrontierState, apply_raw_authority_frontier, inspect_raw_authority_frontier, ) -from polylogue.storage.repair import _stageable_quarantined_census_cohort, inspect_quarantined_accepted_raws from polylogue.storage.sqlite.archive_tiers import revision_governance from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root diff --git a/tests/unit/storage/test_raw_authority_ledger.py b/tests/unit/storage/test_raw_authority_ledger.py index 810da185a7..c73688e76f 100644 --- a/tests/unit/storage/test_raw_authority_ledger.py +++ b/tests/unit/storage/test_raw_authority_ledger.py @@ -15,11 +15,10 @@ from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.core.json import JSONDocument, json_document -from polylogue.maintenance.models import MaintenanceCategory from polylogue.sources.revision_backfill import census_historical_revision_evidence from polylogue.storage import raw_authority as raw_authority_mod +from polylogue.storage import raw_convergence as raw_convergence_mod from polylogue.storage import raw_reconciler as raw_reconciler_mod -from polylogue.storage import repair as repair_mod from polylogue.storage.archive_identity import resolve_active_index_path from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot, raw_materialization_ready from polylogue.storage.blob_store import BlobStore @@ -40,13 +39,13 @@ resolve_raw_authority_blocker, validate_raw_replay_plan, ) +from polylogue.storage.raw_convergence import RawConvergenceResult, converge_raw_materialization from polylogue.storage.raw_reconciler import ( RawAuthorityActuator, RawAuthorityFrontierItem, RawAuthorityFrontierState, inspect_raw_authority_frontier, ) -from polylogue.storage.repair import RepairResult, repair_raw_materialization from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database from polylogue.storage.sqlite.archive_tiers.revision_application import RevisionApplicationReceipt @@ -275,8 +274,8 @@ def test_moved_path_census_stabilizes_preview_and_apply_plan_identity(tmp_path: # before an immutable plan is assigned. census_historical_revision_evidence(tmp_path, selected_raw_ids=[old_raw]) - preview = repair_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) - applied = repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + preview = converge_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) + applied = converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert len(preview.plan_outcomes) == len(applied.plan_outcomes) == 1 assert preview.plan_outcomes[0].plan_id == applied.plan_outcomes[0].plan_id @@ -289,12 +288,12 @@ def test_census_ledger_conserves_unselected_plan_and_application_receipt(tmp_pat _write_codex_raw(tmp_path, native_id="first", source_path="first.jsonl", acquired_at_ms=1) _write_codex_raw(tmp_path, native_id="second", source_path="second.jsonl", acquired_at_ms=2) - incomplete = repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + incomplete = converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert incomplete.census_receipt is not None assert incomplete.census_receipt.quiescent is False assert incomplete.census_receipt.plan_count == 0 - result = repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + result = converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert result.census_receipt is not None assert result.census_receipt.plan_count == 2 @@ -370,10 +369,10 @@ def test_census_ledger_conserves_unselected_plan_and_application_receipt(tmp_pat def test_two_successive_quiescent_censuses_are_required_for_fixed_point(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) _write_codex_raw(tmp_path, native_id="fixed", source_path="fixed.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 - first_empty = repair_raw_materialization(_config(tmp_path), dry_run=True) - second_empty = repair_raw_materialization(_config(tmp_path), dry_run=True) + first_empty = converge_raw_materialization(_config(tmp_path), dry_run=True) + second_empty = converge_raw_materialization(_config(tmp_path), dry_run=True) assert first_empty.census_receipt is not None assert second_empty.census_receipt is not None @@ -417,14 +416,14 @@ def test_stale_plan_persists_blocker_before_automatic_replay_refuses_work(tmp_pa ).fetchone()[0] == "rejected_stale" ) - refused = repair_raw_materialization(_config(tmp_path)) + refused = converge_raw_materialization(_config(tmp_path)) assert refused.success is False assert refused.metrics["raw_materialization_unresolved_blocker_count"] == 1.0 assert raw_materialization_ready(raw_materialization_readiness_snapshot(tmp_path)) is False def test_auto_resolve_stale_plan_blockers_unblocks_materialization_unattended(tmp_path: Path) -> None: - """polylogue-d7im: one stale-plan blocker halts repair_materialization + """polylogue-d7im: one stale-plan blocker halts converge_materialization archive-wide (unresolved_raw_replay_blockers counts it), even though resolving it requires no operator judgment -- it only recomputes the plan from current evidence, exactly as an unattended crash-recovery pass @@ -451,7 +450,7 @@ def test_auto_resolve_stale_plan_blockers_unblocks_materialization_unattended(tm assert valid is False reject_stale_raw_replay_plan(tmp_path, census.census_id, plan, observed) - refused = repair_raw_materialization(_config(tmp_path)) + refused = converge_raw_materialization(_config(tmp_path)) assert refused.success is False resolved_count = auto_resolve_stale_plan_blockers(tmp_path) @@ -466,7 +465,7 @@ def test_auto_resolve_stale_plan_blockers_unblocks_materialization_unattended(tm ).fetchone()[0] assert resolution == AUTO_STALE_PLAN_RESOLUTION - proceeds = repair_raw_materialization(_config(tmp_path)) + proceeds = converge_raw_materialization(_config(tmp_path)) assert proceeds.metrics.get("raw_materialization_unresolved_blocker_count", 0.0) == 0.0 # Idempotent: nothing left to clear on a second call. @@ -874,7 +873,7 @@ def test_global_census_quiesces_moved_component_before_any_plan_is_published(tmp incomplete_receipts = [] for _expected_pass in range(2): - incomplete = repair_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) + incomplete = converge_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) assert incomplete.census_receipt is not None assert incomplete.census_receipt.quiescent is False assert incomplete.census_receipt.plan_count == 0 @@ -893,7 +892,7 @@ def test_global_census_quiesces_moved_component_before_any_plan_is_published(tmp incomplete_receipts.append(incomplete.census_receipt.census_id) assert len(set(incomplete_receipts)) == 2 - preview = repair_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) + preview = converge_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_limit=1) assert preview.census_receipt is not None assert preview.census_receipt.quiescent is True @@ -962,9 +961,11 @@ def test_interrupted_apply_recovers_exact_durable_postconditions(tmp_path: Path) text="interrupted-authority-fts-needle", ) - with patch.object(repair_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash")): + with patch.object( + raw_convergence_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash") + ): with pytest.raises(RuntimeError, match="synthetic crash"): - repair_raw_materialization(_config(tmp_path)) + converge_raw_materialization(_config(tmp_path)) with sqlite3.connect(tmp_path / "source.db") as conn: assert ( @@ -1009,7 +1010,7 @@ def test_interrupted_apply_recovers_exact_durable_postconditions(tmp_path: Path) assert fts_before_resume["exists"] is True assert fts_hits_before_resume - recovered = repair_raw_materialization(_config(tmp_path)) + recovered = converge_raw_materialization(_config(tmp_path)) assert recovered.metrics["raw_materialization_recovered_census_count"] == 1.0 with sqlite3.connect(tmp_path / "source.db") as conn: row = conn.execute( @@ -1064,9 +1065,11 @@ def test_interrupted_recovery_receives_repair_pinned_index_path(tmp_path: Path) initialize_active_archive_root(tmp_path) _write_codex_raw(tmp_path, native_id="pinned-recovery", source_path="pinned-recovery.jsonl", acquired_at_ms=1) - with patch.object(repair_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash")): + with patch.object( + raw_convergence_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash") + ): with pytest.raises(RuntimeError, match="synthetic crash"): - repair_raw_materialization(_config(tmp_path)) + converge_raw_materialization(_config(tmp_path)) expected_index = resolve_active_index_path(tmp_path) recover = raw_authority_mod.recover_interrupted_raw_authority_censuses @@ -1076,8 +1079,10 @@ def capture_pinned_index(root: Path, *, index_db_path: Path | None = None) -> tu received.append(index_db_path) return recover(root, index_db_path=index_db_path) - with patch.object(repair_mod, "recover_interrupted_raw_authority_censuses", side_effect=capture_pinned_index): - result = repair_raw_materialization(_config(tmp_path)) + with patch.object( + raw_convergence_mod, "recover_interrupted_raw_authority_censuses", side_effect=capture_pinned_index + ): + result = converge_raw_materialization(_config(tmp_path)) assert result.metrics["raw_materialization_recovered_census_count"] == 1.0 assert received == [expected_index] @@ -1098,8 +1103,8 @@ def incomplete_receipt( payload["head_rows"] = [] return json_document(payload) - with patch.object(repair_mod, "raw_replay_application_receipt", side_effect=incomplete_receipt): - result = repair_raw_materialization(_config(tmp_path)) + with patch.object(raw_convergence_mod, "raw_replay_application_receipt", side_effect=incomplete_receipt): + result = converge_raw_materialization(_config(tmp_path)) assert result.plan_outcomes[0].status is RawReplayPlanStatus.REJECTED_STALE with sqlite3.connect(tmp_path / "source.db") as conn: @@ -1111,7 +1116,7 @@ def incomplete_receipt( def test_application_receipt_reads_the_active_generation_not_shadow_index(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id="active-receipt", source_path="active.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).success is True + assert converge_raw_materialization(_config(tmp_path)).success is True plan = build_raw_replay_plans(tmp_path, ((raw_id,),))[0] active_index = tmp_path / "generations" / "active" / "index.db" initialize_archive_database(active_index, ArchiveTier.INDEX) @@ -1126,7 +1131,7 @@ def test_application_receipt_reads_the_active_generation_not_shadow_index(tmp_pa def test_replay_plan_build_and_validation_read_the_active_generation(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id="active-plan", source_path="active-plan.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).success is True + assert converge_raw_materialization(_config(tmp_path)).success is True shadow_plan = build_raw_replay_plans(tmp_path, ((raw_id,),))[0] assert shadow_plan.index_preconditions["sessions"] @@ -1159,7 +1164,7 @@ def test_frontier_census_reads_the_active_generation_not_shadow_index(tmp_path: def test_application_receipt_requires_exact_application_authority(tmp_path: Path, field: str) -> None: initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id=f"exact-{field}", source_path=f"{field}.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).success is True + assert converge_raw_materialization(_config(tmp_path)).success is True plan = build_raw_replay_plans(tmp_path, ((raw_id,),))[0] receipt = dict(raw_authority_mod.raw_replay_application_receipt(tmp_path, plan)) application_rows = cast(list[dict[str, object]], receipt["application_rows"]) @@ -1191,7 +1196,7 @@ def test_application_receipt_recovery_rejects_malformed_authority_evidence( ) -> None: initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id=f"malformed-{field}", source_path=f"{field}.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).success is True + assert converge_raw_materialization(_config(tmp_path)).success is True plan = build_raw_replay_plans(tmp_path, ((raw_id,),))[0] receipt = dict(raw_authority_mod.raw_replay_application_receipt(tmp_path, plan)) application_rows = cast(list[dict[str, object]], receipt["application_rows"]) @@ -1208,7 +1213,7 @@ def test_application_receipt_recovery_rejects_source_revision_from_another_share """A grouped raw cannot lend key B's revision evidence to key A's application.""" initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id="shared-memberships", source_path="shared.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).success is True + assert converge_raw_materialization(_config(tmp_path)).success is True plan = build_raw_replay_plans(tmp_path, ((raw_id,),))[0] receipt = dict(raw_authority_mod.raw_replay_application_receipt(tmp_path, plan)) membership_rows = cast(list[dict[str, object]], receipt["membership_rows"]) @@ -1286,15 +1291,17 @@ def test_recovery_rejects_partial_expanded_membership_postconditions(tmp_path: P text="new", ) - with patch.object(repair_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash")): + with patch.object( + raw_convergence_mod, "raw_replay_application_receipt", side_effect=RuntimeError("synthetic crash") + ): with pytest.raises(RuntimeError, match="synthetic crash"): - repair_raw_materialization(_config(tmp_path)) + converge_raw_materialization(_config(tmp_path)) with sqlite3.connect(tmp_path / "source.db") as conn: conn.execute("DELETE FROM raw_session_memberships WHERE raw_id = ?", (second,)) conn.commit() - recovered = repair_raw_materialization(_config(tmp_path)) + recovered = converge_raw_materialization(_config(tmp_path)) assert recovered.success is False assert recovered.metrics["raw_materialization_unresolved_blocker_count"] == 1.0 @@ -1348,7 +1355,7 @@ def test_stale_blocker_resolution_replans_current_evidence_and_resumes(tmp_path: with pytest.raises(RuntimeError, match="raw authority detail changed"): read_raw_authority_detail(tmp_path, stale_continuation, chunk_chars=256) current_detail = _read_detail_document(tmp_path, cast(str, resolution["detail_query_handle"])) - resumed = repair_raw_materialization(_config(tmp_path)) + resumed = converge_raw_materialization(_config(tmp_path)) assert resolution["blocker_id"] == blocker_id resolution_plan = cast(dict[str, object], resolution["current_plan"]) @@ -1446,7 +1453,7 @@ def test_recovery_returns_planned_census_after_all_outcomes_are_recorded(tmp_pat RawReplayPlanOutcome(plan.plan_id, plan.input_raw_ids, RawReplayPlanStatus.EXECUTED, "done", "none"), ) - recovered = repair_raw_materialization(_config(tmp_path)) + recovered = converge_raw_materialization(_config(tmp_path)) assert recovered.metrics["raw_materialization_recovered_census_count"] == 1.0 with sqlite3.connect(tmp_path / "source.db") as conn: @@ -1577,7 +1584,7 @@ def test_fixed_point_compares_residual_identity_and_parser_fingerprint(tmp_path: def test_stale_per_raw_parser_fingerprint_is_recensused_before_planning(tmp_path: Path) -> None: initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id="parser-drift", source_path="parser-drift.jsonl", acquired_at_ms=1) - first = repair_raw_materialization(_config(tmp_path), dry_run=True) + first = converge_raw_materialization(_config(tmp_path), dry_run=True) with sqlite3.connect(tmp_path / "source.db") as conn: conn.execute( "UPDATE raw_authority_parser_census SET parser_fingerprint = 'old-parser' WHERE raw_id = ?", @@ -1585,7 +1592,7 @@ def test_stale_per_raw_parser_fingerprint_is_recensused_before_planning(tmp_path ) conn.commit() - second = repair_raw_materialization(_config(tmp_path), dry_run=True) + second = converge_raw_materialization(_config(tmp_path), dry_run=True) assert first.plan_outcomes[0].plan_id == second.plan_outcomes[0].plan_id with sqlite3.connect(tmp_path / "source.db") as conn: @@ -1636,8 +1643,8 @@ def _seed_ambiguous_membership_component( ) conn.commit() (plan,) = build_raw_replay_plans(tmp_path, [(raw_id,)]) - empty_remaining = repair_mod.RawMaterializationCandidates([], 0, 0) - (outcome,) = repair_mod._raw_replay_plan_outcomes( + empty_remaining = raw_convergence_mod.RawMaterializationCandidates([], 0, 0) + (outcome,) = raw_convergence_mod._raw_replay_plan_outcomes( tmp_path, resolve_active_index_path(tmp_path), [plan], @@ -1671,9 +1678,9 @@ def test_ambiguous_verdict_under_superseded_fingerprint_is_replayable( Anti-vacuity: this exercises the real production route ``repair._raw_replay_plan_outcome`` (via the public ``build_raw_replay_plans``/``_raw_replay_plan_outcomes`` pair used by - ``repair_raw_materialization``, the daemon's live raw-materialization + ``converge_raw_materialization``, the daemon's live raw-materialization repair entrypoint). Reverting the fingerprint-gating clause added to the - terminal query in ``storage/repair.py`` (the ``LEFT JOIN + terminal query in ``storage/raw_convergence.py`` (the ``LEFT JOIN raw_authority_parser_census`` + ``NOT COALESCE(... IN (SELECT value FROM json_each(?)) ...)`` guard) makes this test fail by re-classifying the plan as TERMINAL. @@ -1697,7 +1704,7 @@ def test_ambiguous_verdict_with_no_census_row_stays_terminal(tmp_path: Path) -> assert outcome.status is RawReplayPlanStatus.TERMINAL -def test_repair_result_bounds_public_plan_outcomes() -> None: +def test_raw_convergence_result_bounds_public_plan_outcomes() -> None: outcomes = tuple( RawReplayPlanOutcome( f"plan-{index}", @@ -1708,10 +1715,8 @@ def test_repair_result_bounds_public_plan_outcomes() -> None: ) for index in range(10) ) - result = RepairResult( + result = RawConvergenceResult( "raw_materialization", - MaintenanceCategory.DERIVED_REPAIR, - False, 0, False, plan_outcomes=outcomes, @@ -1721,7 +1726,7 @@ def test_repair_result_bounds_public_plan_outcomes() -> None: assert result["plan_outcomes_truncated"] is True -def test_repair_result_omits_unbounded_receipt_rows_from_outcome_sample() -> None: +def test_raw_convergence_result_omits_unbounded_receipt_rows_from_outcome_sample() -> None: outcome = RawReplayPlanOutcome( "plan-with-receipt", tuple(f"raw-{index}" for index in range(100)), @@ -1730,10 +1735,8 @@ def test_repair_result_omits_unbounded_receipt_rows_from_outcome_sample() -> Non "none", json_document({"application_rows": [{"row": index} for index in range(1000)]}), ) - result = RepairResult( + result = RawConvergenceResult( "raw_materialization", - MaintenanceCategory.DERIVED_REPAIR, - False, 1, True, plan_outcomes=(outcome,), @@ -1762,7 +1765,7 @@ def test_frontier_classifies_dangling_head_session_as_corrupt(tmp_path: Path) -> """ initialize_active_archive_root(tmp_path) raw_id = _write_codex_raw(tmp_path, native_id="dangling-session", source_path="dangling.jsonl", acquired_at_ms=1) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 with sqlite3.connect(tmp_path / "index.db") as index_conn: session_id = index_conn.execute( @@ -1812,7 +1815,7 @@ def test_frontier_classifies_head_session_raw_mismatch_as_corrupt(tmp_path: Path accepted_raw_id = _write_codex_raw( tmp_path, native_id="mismatch-one", source_path="mismatch.jsonl", acquired_at_ms=1 ) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 # An independent, never-materialized raw acquisition -- stands in for the # "wrong" raw a corrupted head could point at. phantom_raw_id = _write_codex_raw(tmp_path, native_id="phantom-only", source_path="phantom.jsonl", acquired_at_ms=2) @@ -1864,7 +1867,7 @@ def test_verified_blob_receipt_invalidates_when_blob_bytes_change_underneath_it( raw_id = _write_codex_raw( tmp_path, native_id="tamper-target", source_path="tamper.jsonl", acquired_at_ms=1, text="hello" ) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 with sqlite3.connect(tmp_path / "source.db") as source_conn: blob_hash_hex = str( @@ -1908,7 +1911,7 @@ def test_verified_blob_receipt_skips_rehash_on_unchanged_blob_across_census_pass raw_id = _write_codex_raw( tmp_path, native_id="unchanged-target", source_path="unchanged.jsonl", acquired_at_ms=1, text="hello" ) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 verify_calls: list[str] = [] real_verify = BlobStore.verify @@ -1964,7 +1967,7 @@ def test_ineligible_quarantined_raw_gets_a_terminal_actuator_not_refine_quaranti raw_id = _write_codex_raw( tmp_path, native_id="quarantine-ineligible", source_path="quarantine.jsonl", acquired_at_ms=1 ) - assert repair_raw_materialization(_config(tmp_path)).repaired_count == 1 + assert converge_raw_materialization(_config(tmp_path)).repaired_count == 1 with sqlite3.connect(tmp_path / "source.db") as source_conn: source_conn.execute( diff --git a/tests/unit/storage/test_raw_convergence.py b/tests/unit/storage/test_raw_convergence.py index a7bdb56c92..fce29dd040 100644 --- a/tests/unit/storage/test_raw_convergence.py +++ b/tests/unit/storage/test_raw_convergence.py @@ -3,8 +3,7 @@ import json import sqlite3 import time -from collections.abc import Iterator, Sequence -from contextlib import contextmanager +from collections.abc import Sequence from pathlib import Path from types import SimpleNamespace from typing import Any, cast @@ -17,14 +16,12 @@ from polylogue.core.json import json_document from polylogue.core.raw_failure_evidence import RawFailureEvidenceKind from polylogue.daemon.status import raw_failure_info_for_root -from polylogue.maintenance.models import DerivedModelStatus, MaintenanceCategory -from polylogue.maintenance.scope import MaintenanceScopeFilter from polylogue.sources.revision_backfill import census_historical_revision_evidence -from polylogue.storage import repair as repair_mod +from polylogue.storage import raw_convergence as raw_convergence_mod from polylogue.storage.blob_publication import ArchiveBlobPublisher from polylogue.storage.blob_store import BlobStore from polylogue.storage.insights.session.repair_assessment import assess_session_insight_repairs -from polylogue.storage.insights.session.runtime import SessionInsightCounts, SessionInsightStatusSnapshot +from polylogue.storage.insights.session.runtime import SessionInsightStatusSnapshot from polylogue.storage.raw.models import RawSessionStateUpdate from polylogue.storage.raw_authority import RawReplayPlan, RawReplayPlanOutcome, RawReplayPlanStatus from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database @@ -88,37 +85,10 @@ def inspect_inner(*_args: object, **_kwargs: object) -> Any: pass raise InnerReachedError - monkeypatch.setattr(repair_mod, "_repair_raw_materialization", inspect_inner) + monkeypatch.setattr(raw_convergence_mod, "_converge_raw_materialization", inspect_inner) with pytest.raises(InnerReachedError): - repair_mod.repair_raw_materialization(config) - with RebuildLease(tmp_path): - pass - - -def test_raw_snapshot_cleanup_binds_authority_and_delete_under_writer_lease( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Promotion cannot change the protected generation during destructive raw cleanup.""" - - from polylogue.storage.index_generation import RebuildLease, RebuildLeaseUnavailableError - - initialize_active_archive_root(tmp_path) - config = Config(archive_root=tmp_path, render_root=tmp_path, sources=[]) - - class InnerReachedError(RuntimeError): - pass - - def inspect_inner(*_args: object, **_kwargs: object) -> Any: - with pytest.raises(RebuildLeaseUnavailableError): - with RebuildLease(tmp_path): - pass - raise InnerReachedError - - monkeypatch.setattr(repair_mod, "_repair_superseded_raw_snapshots", inspect_inner) - - with pytest.raises(InnerReachedError): - repair_mod.repair_superseded_raw_snapshots(config) + raw_convergence_mod.converge_raw_materialization(config) with RebuildLease(tmp_path): pass @@ -129,19 +99,7 @@ def test_raw_materialization_returns_a_typed_failure_while_rebuild_owns_archive( initialize_active_archive_root(tmp_path) with RebuildLease(tmp_path): - result = repair_mod.repair_raw_materialization(_config(tmp_path)) - - assert result.success is False - assert "offline index rebuild owns archive" in result.detail - - -def test_raw_snapshot_cleanup_returns_a_typed_failure_while_rebuild_owns_archive(tmp_path: Path) -> None: - """Destructive raw cleanup reports a lease conflict through RepairResult.""" - from polylogue.storage.index_generation import RebuildLease - - initialize_active_archive_root(tmp_path) - with RebuildLease(tmp_path): - result = repair_mod.repair_superseded_raw_snapshots(_config(tmp_path)) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) assert result.success is False assert "offline index rebuild owns archive" in result.detail @@ -184,7 +142,7 @@ def test_raw_materialization_reparses_legacy_indexed_raw_before_receipting(tmp_p ) conn.commit() - repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=True) + raw_convergence_mod.converge_raw_materialization(_config(tmp_path), dry_run=True) with sqlite3.connect(tmp_path / "source.db") as conn: receipt = conn.execute( @@ -228,7 +186,7 @@ def test_raw_materialization_parser_census_respects_raw_scope(tmp_path: Path) -> conn.execute("DELETE FROM raw_authority_parser_census WHERE raw_id IN (?, ?)", raw_ids) conn.commit() - repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_id=raw_ids[0]) + raw_convergence_mod.converge_raw_materialization(_config(tmp_path), dry_run=True, raw_artifact_id=raw_ids[0]) with sqlite3.connect(tmp_path / "source.db") as conn: receipts = dict( @@ -239,11 +197,13 @@ def test_raw_materialization_parser_census_respects_raw_scope(tmp_path: Path) -> assert raw_ids[1] not in receipts -def _complete_bounded_raw_census(config: Config, *, limit: int) -> tuple[repair_mod.RepairResult, list[str]]: +def _complete_bounded_raw_census( + config: Config, *, limit: int +) -> tuple[raw_convergence_mod.RawConvergenceResult, list[str]]: """Advance census-only passes until a quiescent preview can publish plans.""" incomplete_census_ids: list[str] = [] for _pass in range(1_000): - result = repair_mod.repair_raw_materialization(config, dry_run=True, raw_artifact_limit=limit) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, raw_artifact_limit=limit) assert result.census_receipt is not None if result.census_receipt.quiescent: return result, incomplete_census_ids @@ -254,242 +214,15 @@ def _complete_bounded_raw_census(config: Config, *, limit: int) -> tuple[repair_ raise AssertionError("bounded raw census did not quiesce") -def _repair_after_persisted_census( +def _converge_after_persisted_census( config: Config, *, dry_run: bool = False, raw_artifact_id: str | None = None, -) -> repair_mod.RepairResult: +) -> raw_convergence_mod.RawConvergenceResult: """Exercise replay only after the durable parser census reaches quiescence.""" _complete_bounded_raw_census(config, limit=1_000) - return repair_mod.repair_raw_materialization(config, dry_run=dry_run, raw_artifact_id=raw_artifact_id) - - -def _status( - *, - source_documents: int = 0, - materialized_documents: int = 0, - materialized_rows: int = 0, - pending_documents: int = 0, - pending_rows: int = 0, - stale_rows: int = 0, - orphan_rows: int = 0, -) -> DerivedModelStatus: - return DerivedModelStatus( - name="test", - ready=pending_documents == 0 and pending_rows == 0 and stale_rows == 0 and orphan_rows == 0, - detail="", - source_documents=source_documents, - materialized_documents=materialized_documents, - materialized_rows=materialized_rows, - pending_documents=pending_documents, - pending_rows=pending_rows, - stale_rows=stale_rows, - orphan_rows=orphan_rows, - ) - - -def test_session_insight_repair_count_uses_public_phase_status_key() -> None: - statuses = { - "session_profile_rows": _status(), - "session_work_events": _status(), - "session_work_events_fts": _status(), - "session_phases": _status(pending_rows=2), - "threads": _status(), - "session_tag_rollups": _status(), - } - - assert repair_mod.session_insight_repair_count(statuses) == 2 - - legacy_statuses = dict(statuses) - legacy_statuses["session_phase_inference"] = legacy_statuses.pop("session_phases") - assert repair_mod.session_insight_repair_count(legacy_statuses) == 0 - - legacy_statuses = dict(statuses) - legacy_statuses["session_work_event_inference"] = legacy_statuses.pop("session_work_events") - assert repair_mod.session_insight_repair_count(legacy_statuses) == 0 - - -def test_deleted_orphan_repairs_are_unreachable() -> None: - """The schema FK/CASCADE guarantee replaces these manual repair paths.""" - from polylogue.maintenance.targets import build_maintenance_target_catalog - - catalog = build_maintenance_target_catalog() - assert not hasattr(repair_mod, "repair_orphaned_messages") - assert not hasattr(repair_mod, "repair_orphaned_attachments") - assert not hasattr(repair_mod, "preview_orphaned_messages") - assert not hasattr(repair_mod, "preview_orphaned_attachments") - assert "orphaned_messages" not in repair_mod.REPAIR_HANDLERS - assert "orphaned_attachments" not in repair_mod.REPAIR_HANDLERS - assert "orphaned_messages" not in repair_mod.PREVIEW_HANDLERS - assert "orphaned_attachments" not in repair_mod.PREVIEW_HANDLERS - assert catalog.resolve_name("orphaned_messages") is None - assert catalog.resolve_name("orphaned_attachments") is None - - -def test_session_insights_convergence_matches_repair_archive_route(tmp_path: Path) -> None: - """The daemon's real archive route repairs the same session rows as repair.""" - from polylogue.core.enums import BlockType, Provider, Role - from polylogue.daemon.convergence_stages import make_insights_stage - from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession - from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore - from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root - - initialize_active_archive_root(tmp_path) - source_path = tmp_path / "codex-session.jsonl" - source_path.write_bytes(b"real archive source\n") - session = ParsedSession( - source_name=Provider.CODEX, - provider_session_id="convergence-parity", - title="Convergence parity", - messages=[ - ParsedMessage( - provider_message_id="message-1", - role=Role.USER, - text="Exercise the real archive route.", - position=0, - blocks=[ - ParsedContentBlock( - type=BlockType.TEXT, - text="Exercise the real archive route.", - ) - ], - ) - ], - ) - with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: - _raw_id, session_id = archive.write_raw_and_parsed( - session, - payload=source_path.read_bytes(), - source_path=str(source_path), - acquired_at_ms=1, - ) - - def insight_facts() -> tuple[tuple[object, ...], ...]: - with sqlite3.connect(tmp_path / "index.db") as conn: - profile = conn.execute( - """ - SELECT materializer_version, input_row_count, message_count, word_count - FROM session_profiles WHERE session_id = ? - """, - (session_id,), - ).fetchone() - materialization = conn.execute( - """ - SELECT insight_type, materializer_version, input_row_count - FROM insight_materialization WHERE session_id = ? ORDER BY insight_type - """, - (session_id,), - ).fetchall() - work_events = conn.execute( - """ - SELECT position, work_event_type, summary - FROM session_work_events WHERE session_id = ? ORDER BY position - """, - (session_id,), - ).fetchall() - phases = conn.execute( - """ - SELECT position, start_index, end_index, word_count - FROM session_phases WHERE session_id = ? ORDER BY position - """, - (session_id,), - ).fetchall() - return ( - tuple(profile) if profile is not None else (), - *map(tuple, materialization), - *map(tuple, work_events), - *map(tuple, phases), - ) - - manual_result = repair_mod.repair_session_insights( - _config(tmp_path), - archive_root_override=tmp_path, - ) - assert manual_result.success is True - manual_facts = insight_facts() - - with sqlite3.connect(tmp_path / "index.db") as conn: - conn.execute("DELETE FROM session_profiles WHERE session_id = ?", (session_id,)) - conn.execute("DELETE FROM insight_materialization WHERE session_id = ?", (session_id,)) - conn.commit() - - stage = make_insights_stage(tmp_path / "index.db") - assert stage.check(source_path) is True - stage_result = stage.execute(source_path) - assert getattr(stage_result, "success", stage_result) is True - assert stage.check(source_path) is False - assert insight_facts() == manual_facts - - -def test_preview_counts_from_archive_debt_include_healthy_preview_targets_only() -> None: - statuses = { - "session_insights": repair_mod.ArchiveDebtStatus( - name="session_insights", - category=repair_mod._maintenance_target_spec("session_insights").category, - destructive=False, - issue_count=0, - detail="ready", - maintenance_target="session_insights", - ), - "empty_sessions": repair_mod.ArchiveDebtStatus( - name="empty_sessions", - category=repair_mod._maintenance_target_spec("empty_sessions").category, - destructive=True, - issue_count=4, - detail="needs cleanup", - maintenance_target="empty_sessions", - ), - } - - assert repair_mod.preview_counts_from_archive_debt(statuses) == { - "session_insights": 0, - "empty_sessions": 4, - } - - -def test_probe_only_archive_debt_skips_large_message_scans(monkeypatch: pytest.MonkeyPatch) -> None: - class Conn: - def execute(self, *_args: object, **_kwargs: object) -> object: - raise AssertionError("large probe mode should not run exact SQL scans") - - statuses = { - "messages_fts": _status(), - } - monkeypatch.setattr(repair_mod, "_table_has_more_than", lambda *_args: True) - monkeypatch.setattr(repair_mod, "count_empty_sessions_sync", lambda _conn: (_ for _ in ()).throw(AssertionError)) - debt = repair_mod.collect_archive_debt_statuses_sync( - cast(Any, Conn()), derived_statuses=statuses, include_expensive=False, probe_only=True - ) - - assert debt["empty_sessions"].skipped is True - - -def test_archive_debt_collection_honors_target_scope(monkeypatch: pytest.MonkeyPatch) -> None: - statuses = { - "session_profile_rows": _status(pending_rows=3), - "session_work_events": _status(), - "session_work_events_fts": _status(), - "session_phases": _status(), - "threads": _status(), - "session_tag_rollups": _status(), - } - - def fail_unrelated(*_args: object, **_kwargs: object) -> int: - raise AssertionError("target-scoped session_insights preview must not scan unrelated maintenance debt") - - monkeypatch.setattr(repair_mod, "count_empty_sessions_sync", fail_unrelated) - monkeypatch.setattr(repair_mod, "count_superseded_raw_snapshots_sync", fail_unrelated) - - with sqlite3.connect(":memory:") as conn: - debt = repair_mod.collect_archive_debt_statuses_sync( - conn, - derived_statuses=statuses, - target_names=("session_insights",), - ) - - assert tuple(debt) == ("session_insights",) - assert debt["session_insights"].issue_count == 3 + return raw_convergence_mod.converge_raw_materialization(config, dry_run=dry_run, raw_artifact_id=raw_artifact_id) def test_raw_materialization_preview_counts_replayable_rows_without_erasing_missing_blobs(tmp_path: Path) -> None: @@ -563,7 +296,7 @@ def test_raw_materialization_preview_counts_replayable_rows_without_erasing_miss ) index_conn.commit() - result = repair_mod.repair_raw_materialization(config, dry_run=True) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) assert result.repaired_count == 0 assert result.success is False @@ -610,7 +343,7 @@ def test_raw_materialization_replays_same_native_when_index_raw_link_is_dangling ) index_conn.commit() - result = _repair_after_persisted_census(config, dry_run=True) + result = _converge_after_persisted_census(config, dry_run=True) assert result.success is True assert result.repaired_count == 0 @@ -653,8 +386,8 @@ def test_raw_materialization_split_root_routes_authority_replay(tmp_path: Path) db_path=routed_root / "index.db", ) - backlog = repair_mod.raw_materialization_replay_backlog(config) - result = _repair_after_persisted_census(config) + backlog = raw_convergence_mod.raw_materialization_replay_backlog(config) + result = _converge_after_persisted_census(config) assert backlog["execution_blocked"] is False assert backlog["execution_block_reason"] is None @@ -691,7 +424,7 @@ def test_raw_materialization_retries_typed_transient_lock_failure(tmp_path: Path ) source_conn.commit() - result = repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), dry_run=False) assert result.success is True assert result.repaired_count == 1 @@ -797,11 +530,11 @@ def test_raw_materialization_retries_only_with_deferred_frontier_evidence(tmp_pa ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(config) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(config) assert raw_ids["cas"] in candidates.raw_ids assert raw_ids["sibling"] not in candidates.raw_ids - result = repair_mod.repair_raw_materialization(config, dry_run=True) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) assert result.metrics["raw_materialization_candidate_count"] == 3.0 assert result.metrics["raw_materialization_total_blob_bytes"] == float( @@ -845,7 +578,7 @@ def test_raw_materialization_rejects_contradictory_deferred_evidence(tmp_path: P ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(_config(tmp_path)) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)) assert raw_id not in candidates.raw_ids @@ -891,7 +624,7 @@ def test_raw_materialization_requires_exact_failed_artifact_coordinate(tmp_path: ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(_config(tmp_path)) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)) assert raw_id not in candidates.raw_ids @@ -933,8 +666,8 @@ def test_raw_materialization_validation_failure_cannot_reuse_deferred_authority( source_conn.commit() config = _config(tmp_path) - assert raw_id not in repair_mod._raw_materialization_candidate_ids(config).raw_ids - backlog = repair_mod.raw_materialization_replay_backlog(config) + assert raw_id not in raw_convergence_mod._raw_materialization_candidate_ids(config).raw_ids + backlog = raw_convergence_mod.raw_materialization_replay_backlog(config) assert backlog["candidate_count"] == 0 @@ -957,7 +690,7 @@ def test_raw_materialization_replays_successful_raw_with_historical_validation_f acquired_at_ms=1, ) - assert repair_mod.repair_raw_materialization(_config(tmp_path)).success is True + assert raw_convergence_mod.converge_raw_materialization(_config(tmp_path)).success is True with ArchiveStore.open_existing(tmp_path, read_only=False) as archive: archive.finalize_raw_parse_state( @@ -1004,7 +737,7 @@ def test_raw_materialization_replays_successful_raw_with_historical_validation_f "SELECT COUNT(*) FROM raw_revision_applications WHERE raw_id = ?", (raw_id,) ).fetchone() - replay = repair_mod.repair_raw_materialization(_config(tmp_path)) + replay = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) assert replay.success is True assert replay.repaired_count == 1 @@ -1041,7 +774,7 @@ def test_raw_materialization_refuses_non_parse_authoritative_validation_failure( ) config = _config(tmp_path) - assert repair_mod.repair_raw_materialization(config).success is True + assert raw_convergence_mod.converge_raw_materialization(config).success is True with sqlite3.connect(tmp_path / "source.db") as conn: parsed_at_ms = int( conn.execute("SELECT parsed_at_ms FROM raw_sessions WHERE raw_id = ?", (raw_id,)).fetchone()[0] @@ -1060,8 +793,8 @@ def test_raw_materialization_refuses_non_parse_authoritative_validation_failure( initialize_archive_database(active_index, ArchiveTier.INDEX) (tmp_path / ".index-active-pointer").write_text(f"{active_index}\n", encoding="utf-8") - assert raw_id not in repair_mod._raw_materialization_candidate_ids(config).raw_ids - assert repair_mod.raw_materialization_replay_backlog(config)["candidate_count"] == 0 + assert raw_id not in raw_convergence_mod._raw_materialization_candidate_ids(config).raw_ids + assert raw_convergence_mod.raw_materialization_replay_backlog(config)["candidate_count"] == 0 def test_raw_replay_plan_marks_tied_validation_component_terminal(tmp_path: Path) -> None: @@ -1113,9 +846,11 @@ def test_raw_replay_plan_marks_tied_validation_component_terminal(tmp_path: Path json_document({}), json_document({}), ) - remaining = repair_mod.RawMaterializationCandidates(raw_ids=[], missing_blobs=0, already_parsed=0) + remaining = raw_convergence_mod.RawMaterializationCandidates(raw_ids=[], missing_blobs=0, already_parsed=0) - outcome = repair_mod._raw_replay_plan_outcomes(tmp_path, tmp_path / "index.db", [plan], remaining=remaining)[0] + outcome = raw_convergence_mod._raw_replay_plan_outcomes( + tmp_path, tmp_path / "index.db", [plan], remaining=remaining + )[0] assert outcome.status is RawReplayPlanStatus.TERMINAL @@ -1157,7 +892,7 @@ def test_raw_materialization_does_not_replay_hot_partial_capture(tmp_path: Path, ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(_config(tmp_path)) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)) assert raw_id not in candidates.raw_ids @@ -1189,7 +924,7 @@ def test_raw_materialization_repairs_deferred_stale_frontier_failure(tmp_path: P ) source_conn.commit() - result = repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), dry_run=False) assert result.success is True assert result.repaired_count == 1 @@ -1237,7 +972,7 @@ def test_raw_materialization_preserves_bounded_historical_cas_retry_authority(tm ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(_config(tmp_path)) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)) assert set(candidates.raw_ids) == {raw_ids["prefix"], raw_ids["frontier"], raw_ids["byte"]} @@ -1280,7 +1015,7 @@ def test_raw_materialization_terminal_carrier_overrides_legacy_cas_marker(tmp_pa ) source_conn.commit() - assert repair_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] + assert raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] def test_raw_cas_frontier_error_is_typed_transient() -> None: @@ -1358,7 +1093,7 @@ def test_generic_parse_state_failure_retires_prior_failure_authority(tmp_path: P assert lifecycle.terminal == 0 assert lifecycle.deferred == 0 assert lifecycle.unexplained == 1 - assert repair_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] + assert raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] def test_failed_raw_lifecycle_preserves_exact_evidence_for_same_coordinate( @@ -1429,7 +1164,7 @@ def test_failed_raw_lifecycle_preserves_exact_evidence_for_same_coordinate( assert lifecycle.deferred == 2 assert lifecycle.unexplained == 0 assert {sample["raw_id"] for sample in lifecycle.samples} == {old_raw_id, new_raw_id} - candidates = repair_mod._raw_materialization_candidate_ids(_config(tmp_path)) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)) assert set(candidates.raw_ids) == {old_raw_id, new_raw_id} @@ -1629,8 +1364,8 @@ def test_deferred_cas_evidence_is_superseded_after_resolution_and_non_cas_failur status = raw_failure_info_for_root(tmp_path) assert status["terminal_rejections"] == 0 assert status["unexplained_failures"] == 2 - assert repair_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] - assert repair_mod.raw_materialization_replay_backlog(_config(tmp_path))["candidate_count"] == 0 + assert raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids == [] + assert raw_convergence_mod.raw_materialization_replay_backlog(_config(tmp_path))["candidate_count"] == 0 def test_raw_materialization_split_root_classifies_parsed_sidecar_from_routed_blob(tmp_path: Path) -> None: @@ -1667,7 +1402,7 @@ def test_raw_materialization_split_root_classifies_parsed_sidecar_from_routed_bl db_path=routed_root / "index.db", ) - result = _repair_after_persisted_census(config, dry_run=True) + result = _converge_after_persisted_census(config, dry_run=True) assert result.success is True assert result.repaired_count == 0 @@ -1687,260 +1422,11 @@ def test_raw_materialization_skips_current_non_session_census(tmp_path: Path) -> acquired_at_ms=1, ) - assert raw_id in repair_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids + assert raw_id in raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids census_historical_revision_evidence(tmp_path) - assert raw_id not in repair_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids - - -def test_superseded_raw_cleanup_protects_split_index_referenced_raw_ids(tmp_path: Path) -> None: - config = _config(tmp_path) - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - initialize_archive_database(tmp_path / "index.db", ArchiveTier.INDEX) - source_file = tmp_path / "source.jsonl" - source_file.write_text("{}", encoding="utf-8") - - with sqlite3.connect(tmp_path / "source.db") as source_conn: - source_conn.executemany( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - ( - "raw-referenced-old", - "chatgpt-export", - "native-old", - str(source_file), - 0, - bytes.fromhex("11" * 32), - 10, - 1, - ), - ( - "raw-newer", - "chatgpt-export", - "native-newer", - str(source_file), - 0, - bytes.fromhex("22" * 32), - 11, - 2, - ), - ), - ) - source_conn.commit() - - with sqlite3.connect(tmp_path / "index.db") as index_conn: - index_conn.execute( - """ - INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) - VALUES (?, ?, ?, ?, ?) - """, - ("native-old", "chatgpt-export", "raw-referenced-old", "old", bytes(32)), - ) - index_conn.commit() - - result = repair_mod.repair_superseded_raw_snapshots(config, dry_run=True) - - assert result.repaired_count == 0 - assert "skipped 1 active revision raw rows" in result.detail - - -def test_superseded_raw_cleanup_follows_active_index_pointer(tmp_path: Path) -> None: - config = _config(tmp_path) - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - shadow_index = tmp_path / "index.db" - active_index = tmp_path / "generations" / "active" / "index.db" - initialize_archive_database(shadow_index, ArchiveTier.INDEX) - initialize_archive_database(active_index, ArchiveTier.INDEX) - source_file = tmp_path / "source.jsonl" - source_file.write_text("{}", encoding="utf-8") - - with sqlite3.connect(tmp_path / "source.db") as source_conn: - source_conn.executemany( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - ( - "raw-referenced-old", - "chatgpt-export", - "native-old", - str(source_file), - 0, - bytes.fromhex("11" * 32), - 10, - 1, - ), - ( - "raw-newer", - "chatgpt-export", - "native-newer", - str(source_file), - 0, - bytes.fromhex("22" * 32), - 11, - 2, - ), - ), - ) - source_conn.commit() - with sqlite3.connect(active_index) as index_conn: - index_conn.execute( - """ - INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) - VALUES (?, ?, ?, ?, ?) - """, - ("native-old", "chatgpt-export", "raw-referenced-old", "old", bytes(32)), - ) - index_conn.commit() - (tmp_path / ".index-active-pointer").write_text(f"{active_index}\n", encoding="utf-8") - - result = repair_mod.repair_superseded_raw_snapshots(config, dry_run=True) - - assert result.success is True - assert result.repaired_count == 0 - assert "skipped 1 active revision raw rows" in result.detail - - -def test_superseded_raw_cleanup_preserves_explicit_index_override(tmp_path: Path) -> None: - """An explicit generation remains cleanup authority even when a pointer differs.""" - - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - pointer_index = tmp_path / "generations" / "pointer" / "index.db" - explicit_index = tmp_path / "generations" / "explicit" / "index.db" - initialize_archive_database(pointer_index, ArchiveTier.INDEX) - initialize_archive_database(explicit_index, ArchiveTier.INDEX) - source_file = tmp_path / "source.jsonl" - source_file.write_text("{}", encoding="utf-8") - with sqlite3.connect(tmp_path / "source.db") as source_conn: - source_conn.executemany( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, 'chatgpt-export', ?, ?, 0, ?, ?, ?) - """, - ( - ("raw-explicit", "native-explicit", str(source_file), bytes.fromhex("11" * 32), 10, 1), - ("raw-newer", "native-newer", str(source_file), bytes.fromhex("22" * 32), 11, 2), - ), - ) - source_conn.commit() - with sqlite3.connect(explicit_index) as index_conn: - index_conn.execute( - """ - INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) - VALUES ('native-explicit', 'chatgpt-export', 'raw-explicit', 'explicit', ?) - """, - (bytes(32),), - ) - index_conn.commit() - (tmp_path / ".index-active-pointer").write_text(f"{pointer_index}\n", encoding="utf-8") - config = Config(archive_root=tmp_path, render_root=tmp_path, sources=[], db_path=explicit_index) - - result = repair_mod.repair_superseded_raw_snapshots(config, dry_run=True) - - assert result.success is True - assert result.repaired_count == 0 - assert "skipped 1 active revision raw rows" in result.detail - - -def test_superseded_raw_cleanup_allows_history_before_active_full(tmp_path: Path) -> None: - config = _config(tmp_path) - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - initialize_archive_database(tmp_path / "index.db", ArchiveTier.INDEX) - source_file = tmp_path / "source.jsonl" - source_file.write_text("{}", encoding="utf-8") - with sqlite3.connect(tmp_path / "source.db") as conn: - conn.executemany( - """ - INSERT INTO raw_sessions ( - raw_id, origin, native_id, source_path, source_index, blob_hash, - blob_size, acquired_at_ms, logical_source_key, revision_kind, - source_revision, acquisition_generation, revision_authority - ) VALUES (?, 'codex-session', 'session-1', ?, 0, ?, ?, ?, - 'codex-session:session-1', 'full', ?, ?, 'byte_proven') - """, - ( - ("raw-old-full", str(source_file), bytes.fromhex("11" * 32), 10, 1, "revision-old", 0), - ("raw-new-full", str(source_file), bytes.fromhex("22" * 32), 20, 2, "revision-new", 1), - ), - ) - with sqlite3.connect(tmp_path / "index.db") as conn: - conn.execute( - """INSERT INTO sessions (native_id, origin, raw_id, title, content_hash) - VALUES ('session-1', 'codex-session', 'raw-new-full', 'session', ?)""", - (bytes(32),), - ) - conn.execute( - """ - INSERT INTO raw_revision_heads ( - logical_source_key, session_id, accepted_raw_id, - accepted_source_revision, accepted_content_hash, - accepted_frontier_kind, accepted_frontier, - acquisition_generation, append_end_offset, decided_at_ms - ) VALUES ('codex-session:session-1', 'codex-session:session-1', 'raw-new-full', - 'revision-new', ?, 'byte', 20, 1, NULL, 1) - """, - (bytes(32),), - ) - conn.execute( - """ - INSERT INTO raw_revision_applications ( - decision_id, raw_id, session_id, logical_source_key, - source_revision, acquisition_generation, decision, - accepted_raw_id, accepted_source_revision, accepted_content_hash, - accepted_frontier_kind, accepted_frontier, detail, decided_at_ms - ) VALUES ('old-superseded', 'raw-old-full', 'codex-session:session-1', - 'codex-session:session-1', 'revision-old', 1, 'superseded', - 'raw-new-full', 'revision-new', ?, 'byte', 20, - 'superseded by accepted full', 1) - """, - (bytes(32),), - ) - - result = repair_mod.repair_superseded_raw_snapshots(config, dry_run=True) - - # Anti-vacuity: traversing a full raw's historical cohort would protect - # raw-old-full and reduce this production repair preview to zero. - assert result.success is True - assert result.repaired_count == 1 - - -def test_superseded_raw_cleanup_fails_closed_without_index(tmp_path: Path) -> None: - config = _config(tmp_path) - initialize_archive_database(tmp_path / "source.db", ArchiveTier.SOURCE) - # This valid but unrelated legacy anchor must never authorize deletion - # from the split archive_root/source.db file set. - initialize_archive_database(tmp_path / "archive.db", ArchiveTier.INDEX) - source_file = tmp_path / "source.jsonl" - source_file.write_text("{}", encoding="utf-8") - with sqlite3.connect(tmp_path / "source.db") as conn: - conn.executemany( - """ - INSERT INTO raw_sessions ( - raw_id, origin, source_path, source_index, blob_hash, blob_size, acquired_at_ms - ) VALUES (?, 'chatgpt-export', ?, 0, ?, 10, ?) - """, - ( - ("raw-old", str(source_file), bytes.fromhex("11" * 32), 1), - ("raw-new", str(source_file), bytes.fromhex("22" * 32), 2), - ), - ) - - result = repair_mod.repair_superseded_raw_snapshots(config, dry_run=False) - - # Anti-vacuity: the old fail-open empty-set fallback would delete raw-old. - assert result.success is False - assert result.repaired_count == 0 - assert "index tier is unavailable" in result.detail - with sqlite3.connect(tmp_path / "source.db") as conn: - assert conn.execute("SELECT COUNT(*) FROM raw_sessions").fetchone() == (2,) + assert raw_id not in raw_convergence_mod._raw_materialization_candidate_ids(_config(tmp_path)).raw_ids def test_raw_materialization_retries_restored_missing_blob_parse_errors(tmp_path: Path) -> None: @@ -1988,7 +1474,7 @@ def test_raw_materialization_retries_restored_missing_blob_parse_errors(tmp_path ) source_conn.commit() - result = repair_mod.repair_raw_materialization(config, dry_run=True) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) assert result.repaired_count == 0 assert result.metrics["raw_materialization_candidate_count"] == 1.0 @@ -2024,7 +1510,7 @@ def test_raw_materialization_replays_parsed_rows_when_index_is_empty(tmp_path: P ) source_conn.commit() - result = _repair_after_persisted_census(config, dry_run=True) + result = _converge_after_persisted_census(config, dry_run=True) assert result.repaired_count == 0 assert result.success is True @@ -2085,7 +1571,7 @@ def test_raw_materialization_replays_parsed_rows_after_interrupted_index_rebuild ) index_conn.commit() - result = _repair_after_persisted_census(config, dry_run=True) + result = _converge_after_persisted_census(config, dry_run=True) assert result.repaired_count == 0 assert result.success is True @@ -2169,7 +1655,7 @@ def test_raw_materialization_receipts_partition_terminal_deferred_and_executable ) index_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(config) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(config) assert candidates.raw_ids == [executable_raw_id] assert candidates.adoption_deferred == 1 @@ -2231,7 +1717,7 @@ def test_raw_materialization_replays_complete_governed_bundle_membership_after_i ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(config) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(config) assert set(candidates.raw_ids) == {raw_ids[0], raw_ids[2], raw_ids[3]} assert candidates.authority_quarantined == 1 @@ -2274,7 +1760,7 @@ def conversation(session_id: str) -> dict[str, object]: acquired_at_ms=1, ) - first = repair_mod.repair_raw_materialization(_config(tmp_path)) + first = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) assert first.success is True with sqlite3.connect(tmp_path / "source.db") as conn: assert conn.execute("SELECT status FROM raw_membership_census WHERE raw_id = ?", (raw_id,)).fetchone() == ( @@ -2289,7 +1775,7 @@ def conversation(session_id: str) -> dict[str, object]: (tmp_path / "index.db").unlink() initialize_archive_database(tmp_path / "index.db", ArchiveTier.INDEX) - replay = repair_mod.repair_raw_materialization(_config(tmp_path)) + replay = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) assert replay.success is True assert replay.repaired_count == 2 @@ -2313,9 +1799,9 @@ def test_raw_materialization_reports_uncensused_append_fragments_as_pending_debt ) source_conn.commit() - candidates = repair_mod._raw_materialization_candidate_ids(config) - backlog = repair_mod.raw_materialization_replay_backlog(config) - targeted = repair_mod.repair_raw_materialization(config, raw_artifact_id=raw_id) + candidates = raw_convergence_mod._raw_materialization_candidate_ids(config) + backlog = raw_convergence_mod.raw_materialization_replay_backlog(config) + targeted = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_id=raw_id) assert candidates.raw_ids == [] assert candidates.byte_authority_pending == 1 @@ -2351,8 +1837,8 @@ def test_raw_materialization_reports_uncensused_append_fragments_as_pending_debt assert cursor.rowcount == 1 source_conn.commit() - governed = repair_mod._raw_materialization_candidate_ids(config) - governed_target = repair_mod.repair_raw_materialization(config, raw_artifact_id=raw_id) + governed = raw_convergence_mod._raw_materialization_candidate_ids(config) + governed_target = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_id=raw_id) assert governed.byte_authority_pending == 0 assert governed.byte_authority_quarantined == 1 @@ -2365,7 +1851,7 @@ def test_raw_materialization_reports_uncensused_append_fragments_as_pending_debt ) source_conn.commit() - proven = repair_mod._raw_materialization_candidate_ids(config) + proven = raw_convergence_mod._raw_materialization_candidate_ids(config) assert proven.byte_authority_quarantined == 0 assert proven.byte_authority_fragments == 1 @@ -2415,12 +1901,12 @@ def test_raw_materialization_ordinary_replay_reaches_two_call_fixed_point(tmp_pa ) source_conn.commit() - first = _repair_after_persisted_census(config) + first = _converge_after_persisted_census(config) with sqlite3.connect(tmp_path / "index.db") as index_conn: receipts_after_first = index_conn.execute( "SELECT decision_id, raw_id, decision FROM raw_revision_applications ORDER BY decision_id" ).fetchall() - second = repair_mod.repair_raw_materialization(config) + second = raw_convergence_mod.converge_raw_materialization(config) with sqlite3.connect(tmp_path / "index.db") as index_conn: receipts_after_second = index_conn.execute( "SELECT decision_id, raw_id, decision FROM raw_revision_applications ORDER BY decision_id" @@ -2442,7 +1928,7 @@ def test_raw_materialization_no_progress_component_terminalizes_instead_of_loopi Reproduces the exact production-shaped defect this bead names: a raw is classified as a replayable/selected authority component by - ``repair_raw_materialization``, but its logical cohort has no unique + ``converge_raw_materialization``, but its logical cohort has no unique byte-proven full baseline (a genuinely orphaned ``append``-kind row with no sibling ``full`` row for the same ``logical_source_key``) and no membership evidence either -- so ``backfill_historical_revision_evidence`` @@ -2513,7 +1999,7 @@ def test_raw_materialization_no_progress_component_terminalizes_instead_of_loopi ) source_conn.commit() - first = _repair_after_persisted_census(config) + first = _converge_after_persisted_census(config) assert first.success is False assert first.repaired_count == 0 assert first.metrics.get("raw_materialization_no_progress_count") == 1.0 @@ -2526,7 +2012,7 @@ def test_raw_materialization_no_progress_component_terminalizes_instead_of_loopi ).fetchone()[0] assert terminal_rows_after_first == 1 - second = repair_mod.repair_raw_materialization(config) + second = raw_convergence_mod.converge_raw_materialization(config) # The stalled plan must not be reselected: no second execution attempt, # so the metric that only appears when a component is actually selected # for this pass is absent, and the terminal receipt count is unchanged @@ -2613,7 +2099,7 @@ async def parse_from_raw(self, *, raw_ids: list[str], **kwargs: object) -> objec monkeypatch.setattr(parsing_module, "ParsingService", FakeParsingService) - result = _repair_after_persisted_census(config) + result = _converge_after_persisted_census(config) assert result.success is True assert result.repaired_count == 2 @@ -2710,7 +2196,7 @@ def __init__(self, *_args: object, **_kwargs: object) -> None: monkeypatch.setattr("polylogue.pipeline.services.parsing.ParsingService", UnexpectedParsingService) - result = _repair_after_persisted_census(config) + result = _converge_after_persisted_census(config) assert result.success is False assert result.repaired_count == 0 @@ -2758,7 +2244,7 @@ def __init__(self, *_args: object, **_kwargs: object) -> None: readiness_categories = cast(dict[str, int], readiness["category_counts"]) assert readiness_categories["adoption_deferred"] == 1 - retry = repair_mod.repair_raw_materialization(config, dry_run=False) + retry = raw_convergence_mod.converge_raw_materialization(config, dry_run=False) assert retry.success is False assert retry.metrics["raw_materialization_candidate_count"] == 0.0 assert retry.metrics["raw_materialization_adoption_deferred_count"] == 1.0 @@ -2834,7 +2320,7 @@ def test_raw_materialization_execute_limits_authority_selection( config = _config(tmp_path) preview, incomplete_censuses = _complete_bounded_raw_census(config, limit=2) - result = repair_mod.repair_raw_materialization(config, raw_artifact_limit=2) + result = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=2) assert len(incomplete_censuses) == 1 assert len(preview.plan_outcomes) == 2 @@ -2885,8 +2371,8 @@ def test_raw_materialization_raw_artifact_filter_counts_only_target(tmp_path: Pa ) source_conn.commit() - broad = repair_mod.repair_raw_materialization(config, dry_run=True) - scoped = repair_mod.repair_raw_materialization(config, dry_run=True, raw_artifact_id=target_raw_id) + broad = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) + scoped = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, raw_artifact_id=target_raw_id) assert broad.repaired_count == 0 assert scoped.repaired_count == 0 @@ -2935,13 +2421,13 @@ def test_raw_materialization_excludes_already_parsed_non_materialized_rows(tmp_p ) source_conn.commit() - result = _repair_after_persisted_census(config, dry_run=True) + result = _converge_after_persisted_census(config, dry_run=True) assert result.repaired_count == 0 assert result.metrics["raw_materialization_candidate_count"] == 2.0 assert "1 already parsed but not materialized" in result.detail - scoped = _repair_after_persisted_census(config, dry_run=True, raw_artifact_id=parsed_raw_id) + scoped = _converge_after_persisted_census(config, dry_run=True, raw_artifact_id=parsed_raw_id) assert scoped.repaired_count == 0 assert scoped.metrics["raw_materialization_candidate_count"] == 1.0 @@ -2980,8 +2466,8 @@ def test_raw_materialization_excludes_parsed_non_session_artifacts(tmp_path: Pat ) source_conn.commit() - broad = repair_mod.repair_raw_materialization(config, dry_run=True) - scoped = repair_mod.repair_raw_materialization(config, dry_run=True, raw_artifact_id=raw_id) + broad = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) + scoped = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, raw_artifact_id=raw_id) assert broad.repaired_count == 0 assert scoped.repaired_count == 0 @@ -3027,9 +2513,13 @@ def test_raw_materialization_explicit_scope_includes_already_parsed_rows(tmp_pat source_conn.commit() _complete_bounded_raw_census(config, limit=1_000) - broad = repair_mod.repair_raw_materialization(config, dry_run=True) - by_family = repair_mod.repair_raw_materialization(config, dry_run=True, source_family="gemini-cli-session") - by_root = repair_mod.repair_raw_materialization(config, dry_run=True, source_root=Path("/captures/gemini")) + broad = raw_convergence_mod.converge_raw_materialization(config, dry_run=True) + by_family = raw_convergence_mod.converge_raw_materialization( + config, dry_run=True, source_family="gemini-cli-session" + ) + by_root = raw_convergence_mod.converge_raw_materialization( + config, dry_run=True, source_root=Path("/captures/gemini") + ) assert broad.repaired_count == 0 assert by_family.repaired_count == 0 @@ -3107,9 +2597,11 @@ def test_raw_materialization_scope_filters_count_only_matching_raw_rows(tmp_path ) source_conn.commit() - by_provider = repair_mod.repair_raw_materialization(config, dry_run=True, provider="claude-code") - by_family = repair_mod.repair_raw_materialization(config, dry_run=True, source_family="codex-session") - by_root = repair_mod.repair_raw_materialization(config, dry_run=True, source_root=Path("/captures/claude")) + by_provider = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, provider="claude-code") + by_family = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, source_family="codex-session") + by_root = raw_convergence_mod.converge_raw_materialization( + config, dry_run=True, source_root=Path("/captures/claude") + ) assert by_provider.repaired_count == 0 assert by_family.repaired_count == 0 @@ -3121,7 +2613,7 @@ def test_raw_materialization_scope_filters_count_only_matching_raw_rows(tmp_path assert by_provider.metrics["raw_materialization_max_blob_bytes"] == float( max(claude_size, other_root_size, learned_size) ) - census_candidates = repair_mod._raw_materialization_parser_census_candidates( + census_candidates = raw_convergence_mod._raw_materialization_parser_census_candidates( config, provider="claude-code", ) @@ -3156,7 +2648,7 @@ def __init__(self, **_kwargs: object) -> None: monkeypatch.setattr("polylogue.pipeline.services.parsing.ParsingService", UnexpectedParsingService) - result = repair_mod.repair_raw_materialization(config, dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=False) assert result.success is True assert result.repaired_count == 1 @@ -3189,7 +2681,7 @@ def test_raw_materialization_reports_authority_progress_and_payload_size( config = _config(tmp_path) progress: list[str] = [] - result = repair_mod.repair_raw_materialization( + result = raw_convergence_mod.converge_raw_materialization( config, dry_run=False, progress_callback=lambda _amount, desc=None: progress.append(desc or ""), @@ -3231,7 +2723,7 @@ def __init__(self, **_kwargs: object) -> None: monkeypatch.setattr("polylogue.pipeline.services.parsing.ParsingService", UnexpectedParsingService) - result = repair_mod.repair_raw_materialization(config, dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=False) with sqlite3.connect(tmp_path / "source.db") as conn: first_census_count = int(conn.execute("SELECT COUNT(*) FROM raw_authority_censuses").fetchone()[0]) parser_fingerprint = str( @@ -3239,7 +2731,7 @@ def __init__(self, **_kwargs: object) -> None: "SELECT parser_fingerprint FROM raw_authority_parser_census WHERE raw_id = ?", (raw_id,) ).fetchone()[0] ) - repeated = repair_mod.repair_raw_materialization(config, dry_run=False) + repeated = raw_convergence_mod.converge_raw_materialization(config, dry_run=False) with sqlite3.connect(tmp_path / "source.db") as conn: repeated_census_count = int(conn.execute("SELECT COUNT(*) FROM raw_authority_censuses").fetchone()[0]) @@ -3284,7 +2776,7 @@ def test_raw_materialization_classifies_oversized_stream_record_replay( lambda *_args, **_kwargs: pytest.fail("stream-safe oversized replay must not eagerly read a blob"), ) - result = repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), dry_run=False) assert result.success is False assert result.repaired_count == 0 @@ -3321,7 +2813,7 @@ def test_raw_materialization_blocks_oversized_expanded_cohort_before_blob_open( with sqlite3.connect(tmp_path / "source.db") as source_conn: source_conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, oversized_raw), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, oversized_raw), ) source_conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=[small_raw, oversized_raw]) @@ -3330,7 +2822,7 @@ def test_raw_materialization_blocks_oversized_expanded_cohort_before_blob_open( "polylogue.sources.revision_backfill._parse_retained_raw", lambda *_args, **_kwargs: pytest.fail("expanded cohort size must be checked before opening any blob"), ) - result = repair_mod.repair_raw_materialization( + result = raw_convergence_mod.converge_raw_materialization( _config(tmp_path), raw_artifact_id=small_raw, dry_run=False, @@ -3363,7 +2855,7 @@ def test_raw_materialization_backlog_expands_to_oversized_materialized_sibling( with sqlite3.connect(tmp_path / "source.db") as source_conn: source_conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, large_raw), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, large_raw), ) source_conn.commit() with sqlite3.connect(tmp_path / "index.db") as index_conn: @@ -3374,7 +2866,7 @@ def test_raw_materialization_backlog_expands_to_oversized_materialized_sibling( index_conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=[small_raw, large_raw]) - backlog = repair_mod.raw_materialization_replay_backlog(_config(tmp_path)) + backlog = raw_convergence_mod.raw_materialization_replay_backlog(_config(tmp_path)) assert backlog["candidate_count"] == 1 assert backlog["expanded_candidate_count"] == 2 assert backlog["execution_blocked"] is True @@ -3384,7 +2876,7 @@ def test_raw_materialization_backlog_expands_to_oversized_materialized_sibling( "polylogue.sources.revision_backfill._parse_retained_raw", lambda *_args, **_kwargs: pytest.fail("oversized materialized sibling must block before blob open"), ) - result = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_id=small_raw) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_id=small_raw) assert result.success is False assert "authority components" in result.detail @@ -3417,7 +2909,7 @@ def test_raw_materialization_blocks_aggregate_sub_limit_cohort_before_blob_open( source_conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=raw_ids) - backlog = repair_mod.raw_materialization_replay_backlog(_config(tmp_path)) + backlog = raw_convergence_mod.raw_materialization_replay_backlog(_config(tmp_path)) assert backlog["oversized_count"] == 0 assert backlog["expanded_aggregate_blocked"] is True assert backlog["execution_blocked"] is True @@ -3426,8 +2918,8 @@ def test_raw_materialization_blocks_aggregate_sub_limit_cohort_before_blob_open( "polylogue.sources.revision_backfill._parse_retained_raw", lambda *_args, **_kwargs: pytest.fail("aggregate cohort limit must be checked before blob open"), ) - result = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) - repeated = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + repeated = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert result.success is False assert result.metrics["raw_materialization_resource_blocked_count"] == 2.0 assert len(result.plan_outcomes) == 1 @@ -3456,11 +2948,11 @@ def test_raw_materialization_reuses_pre_envelope_deferred_receipt(tmp_path: Path with sqlite3.connect(tmp_path / "source.db") as conn: conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, raw_id), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, raw_id), ) conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=[raw_id]) - first = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + first = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert first.census_receipt is not None with sqlite3.connect(tmp_path / "source.db") as conn: scope = json.loads( @@ -3478,7 +2970,7 @@ def test_raw_materialization_reuses_pre_envelope_deferred_receipt(tmp_path: Path ) conn.commit() - repeated = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + repeated = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert repeated.success is True assert repeated.census_receipt is not None @@ -3505,7 +2997,9 @@ def test_raw_materialization_reports_the_active_custom_payload_envelope(tmp_path conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=[raw_id]) - result = repair_mod.repair_raw_materialization(_config(tmp_path), dry_run=True, max_payload_bytes=max_payload_bytes) + result = raw_convergence_mod.converge_raw_materialization( + _config(tmp_path), dry_run=True, max_payload_bytes=max_payload_bytes + ) assert result.success is True assert result.metrics["raw_materialization_execute_blob_limit_bytes"] == float(max_payload_bytes) @@ -3543,11 +3037,12 @@ def test_raw_materialization_processes_independent_components_across_bounded_pas source_conn.commit() config = _config(tmp_path) - backlog = repair_mod.raw_materialization_replay_backlog(config) + backlog = raw_convergence_mod.raw_materialization_replay_backlog(config) assert backlog["candidate_count"] == raw_count assert backlog["authority_component_count"] == raw_count assert ( - int(cast(int, backlog["expanded_total_blob_bytes"])) > repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + int(cast(int, backlog["expanded_total_blob_bytes"])) + > raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES ) assert backlog["execution_blocked"] is False assert backlog["executable_authority_component_count"] == raw_count @@ -3557,10 +3052,10 @@ def test_raw_materialization_processes_independent_components_across_bounded_pas assert len(preview.plan_outcomes) == 5 repaired_per_pass: list[int] = [] for _pass in range(5): - result = repair_mod.repair_raw_materialization(config, raw_artifact_limit=5) + result = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=5) repaired_per_pass.append(result.repaired_count) assert repaired_per_pass == [5, 5, 5, 5, 5] - assert repair_mod.repair_raw_materialization(config, raw_artifact_limit=5).success is True + assert raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=5).success is True def test_raw_materialization_max_pass_seconds_bounds_one_pass_and_preserves_progress( @@ -3609,7 +3104,7 @@ def test_raw_materialization_max_pass_seconds_bounds_one_pass_and_preserves_prog elapsed = iter(float(step) * 100.0 for step in range(1000)) monkeypatch.setattr(time, "monotonic", lambda: next(elapsed)) - bounded = repair_mod.repair_raw_materialization( + bounded = raw_convergence_mod.converge_raw_materialization( config, raw_artifact_limit=raw_count, max_pass_seconds=1.0, @@ -3621,7 +3116,7 @@ def test_raw_materialization_max_pass_seconds_bounds_one_pass_and_preserves_prog assert bounded.success is False monkeypatch.undo() - remainder = repair_mod.repair_raw_materialization(config, raw_artifact_limit=raw_count) + remainder = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=raw_count) assert remainder.repaired_count == raw_count - 1 assert remainder.success is True @@ -3653,23 +3148,23 @@ def test_raw_materialization_durable_ledger_survives_ops_reset_for_fairness( with sqlite3.connect(tmp_path / "source.db") as conn: conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, raw_ids[0]), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES + 1, raw_ids[0]), ) conn.commit() census_historical_revision_evidence(tmp_path, selected_raw_ids=raw_ids) - original_stream_safe = repair_mod._raw_materialization_stream_safe + original_stream_safe = raw_convergence_mod._raw_materialization_stream_safe monkeypatch.setattr( - repair_mod, + raw_convergence_mod, "_raw_materialization_stream_safe", lambda candidates, raw_id: raw_id != raw_ids[0] and original_stream_safe(candidates, raw_id), ) - first = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + first = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert first.plan_outcomes[0].status.value == "terminal" (tmp_path / "ops.db").unlink() - second = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=1) + second = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=1) assert second.repaired_count == 1 assert second.plan_outcomes[0].input_raw_ids == (raw_ids[1],) @@ -3718,9 +3213,9 @@ def acquisition_only_order( key=lambda component: min(candidates.raw_acquired_at_ms[raw_id] for raw_id in component), ) - mutation.setattr(repair_mod, "_raw_materialization_ordered_components", acquisition_only_order) - first = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=1) - second = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=1) + mutation.setattr(raw_convergence_mod, "_raw_materialization_ordered_components", acquisition_only_order) + first = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=1) + second = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=1) assert first.plan_outcomes[0].input_raw_ids == (raw_ids[0],) return first.plan_outcomes[0].input_raw_ids, second.plan_outcomes[0].input_raw_ids @@ -3772,7 +3267,7 @@ def run(*, prefer_cheap: bool) -> tuple[tuple[str, ...], str]: # generating megabytes of fixture bytes. source_conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES // 2, large_raw_id), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES // 2, large_raw_id), ) source_conn.commit() @@ -3798,8 +3293,8 @@ def cheap_first_order( ), ) - mutation.setattr(repair_mod, "_raw_materialization_ordered_components", cheap_first_order) - result = repair_mod.repair_raw_materialization(config, raw_artifact_limit=1) + mutation.setattr(raw_convergence_mod, "_raw_materialization_ordered_components", cheap_first_order) + result = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=1) return result.plan_outcomes[0].input_raw_ids, large_raw_id fair_selected, fair_large_id = run(prefer_cheap=False) @@ -3842,7 +3337,7 @@ def fail_oldest(*args: Any, selected_raw_ids: list[str] | None = None, **kwargs: return original(*args, selected_raw_ids=selected_raw_ids, **kwargs) monkeypatch.setattr(revision_backfill, "backfill_historical_revision_evidence", fail_oldest) - result = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_limit=3) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path), raw_artifact_limit=3) assert result.repaired_count == 2 assert [outcome.status.value for outcome in result.plan_outcomes].count("retryable") == 1 @@ -3856,14 +3351,13 @@ def test_raw_materialization_replay_scopes_derived_rebuild_to_touched_component( FTS/trigram/action_pairs/delegation_facts only for the session(s) that component touched -- never the archive-wide ``rebuild_fts_index_sync`` / ``rebuild_command_trigram_index_sync`` / ``rebuild_all_action_pairs_sync`` / - ``rebuild_all_delegation_facts_sync`` quartet ``maintenance/rebuild_index.py``'s - terminal blue-green pass owns. Proven at fixture scale with N pre-existing, + ``rebuild_all_delegation_facts_sync`` quartet. Proven at fixture scale with N pre-existing, fully materialized sessions already holding real ``action_pairs`` rows (each carries one Codex ``function_call``/``function_call_output`` pair): patching all four archive-wide rebuild functions to raise, then replaying exactly one NEW single-session component, must still succeed without tripping any of them. Reverting ``bulk_build=False`` back to ``True`` in - ``repair_raw_materialization`` (the exact regression this guards) makes + ``converge_raw_materialization`` (the exact regression this guards) makes this test fail immediately on the patched raise, not on some indirect symptom. """ @@ -3897,7 +3391,7 @@ def _tool_call_payload(native_id: str) -> bytes: ) config = _config(tmp_path) - baseline = repair_mod.repair_raw_materialization(config) + baseline = raw_convergence_mod.converge_raw_materialization(config) assert baseline.success is True assert baseline.repaired_count == len(existing_native_ids) @@ -3929,7 +3423,7 @@ def _fail(*_args: object, **_kwargs: object) -> None: acquired_at_ms=1000, ) - result = repair_mod.repair_raw_materialization(config) + result = raw_convergence_mod.converge_raw_materialization(config) assert result.success is True assert result.repaired_count == 1 @@ -3995,7 +3489,7 @@ def fail_once(*args: Any, selected_raw_ids: list[str] | None = None, **kwargs: A monkeypatch.setattr(revision_backfill, "backfill_historical_revision_evidence", fail_once) config = _config(tmp_path) - first = repair_mod.repair_raw_materialization(config) + first = raw_convergence_mod.converge_raw_materialization(config) assert first.plan_outcomes[0].status.value == "retryable" assert "database is locked" in first.plan_outcomes[0].reason first_plan_id = first.plan_outcomes[0].plan_id @@ -4008,7 +3502,7 @@ def fail_once(*args: Any, selected_raw_ids: list[str] | None = None, **kwargs: A ) should_fail = False - second = repair_mod.repair_raw_materialization(config) + second = raw_convergence_mod.converge_raw_materialization(config) assert second.plan_outcomes[0].status.value == "executed" assert second.plan_outcomes[0].plan_id == first_plan_id @@ -4051,7 +3545,7 @@ def raise_cas_conflict(*args: Any, selected_raw_ids: list[str] | None = None, ** raise RuntimeError(cas_message) monkeypatch.setattr(revision_backfill, "backfill_historical_revision_evidence", raise_cas_conflict) - result = repair_mod.repair_raw_materialization(_config(tmp_path)) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) outcome = result.plan_outcomes[0] assert outcome.status.value == "retryable" @@ -4108,7 +3602,7 @@ def test_raw_materialization_fails_closed_on_plan_conservation_mismatch( acquired_at_ms=1, ) - original = repair_mod._raw_replay_conservation_metrics + original = raw_convergence_mod._raw_replay_conservation_metrics def corrupt_outcome_algebra( plans: Sequence[RawReplayPlan], @@ -4118,8 +3612,8 @@ def corrupt_outcome_algebra( plan_count, carried_forward, _errors = original(plans, selected_plan_ids, outcomes) return plan_count, carried_forward, 1 - monkeypatch.setattr(repair_mod, "_raw_replay_conservation_metrics", corrupt_outcome_algebra) - result = repair_mod.repair_raw_materialization(_config(tmp_path)) + monkeypatch.setattr(raw_convergence_mod, "_raw_replay_conservation_metrics", corrupt_outcome_algebra) + result = raw_convergence_mod.converge_raw_materialization(_config(tmp_path)) assert result.repaired_count == 1 assert result.metrics["raw_materialization_plan_conservation_error_count"] == 1.0 @@ -4183,13 +3677,13 @@ def test_raw_materialization_batch_limit_counts_authority_components(tmp_path: P source_conn.commit() config = _config(tmp_path) - before = repair_mod.raw_materialization_replay_backlog(config) + before = raw_convergence_mod.raw_materialization_replay_backlog(config) assert before["candidate_count"] == 9 assert before["authority_component_count"] == 5 preview, incomplete_censuses = _complete_bounded_raw_census(config, limit=3) - first = repair_mod.repair_raw_materialization(config, raw_artifact_limit=3) - after = repair_mod.raw_materialization_replay_backlog(config) + first = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=3) + after = raw_convergence_mod.raw_materialization_replay_backlog(config) # The first bounded attempt discovers the five-revision shared component # transitively; the next pass handles the remaining independent components @@ -4238,7 +3732,7 @@ def __init__(self, **_kwargs: object) -> None: lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("synthetic retained-byte decode failure")), ) - result = repair_mod.repair_raw_materialization(config, dry_run=False) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=False) assert result.success is False assert result.repaired_count == 0 @@ -4246,432 +3740,6 @@ def __init__(self, **_kwargs: object) -> None: assert result.metrics["raw_materialization_already_parsed_count"] == 1.0 -def _ready_session_insight_status() -> SessionInsightStatusSnapshot: - return SessionInsightStatusSnapshot( - profile_rows_ready=True, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=True, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - run_rows_ready=True, - observed_event_rows_ready=True, - context_snapshot_rows_ready=True, - threads_ready=True, - tag_rollups_ready=True, - ) - - -def test_repair_session_insights_noops_when_ready(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - @contextmanager - def fake_connection_context(_path: Path) -> Iterator[object]: - yield object() - - def fail_rebuild(*args: object, **kwargs: object) -> int: - raise AssertionError("ready session insights must not run a full rebuild") - - monkeypatch.setattr("polylogue.storage.sqlite.connection.connection_context", fake_connection_context) - monkeypatch.setattr( - "polylogue.storage.insights.session.status.session_insight_status_sync", - lambda _conn: _ready_session_insight_status(), - ) - monkeypatch.setattr( - "polylogue.storage.insights.session.rebuild.rebuild_session_insights_sync", - fail_rebuild, - ) - - result = repair_mod.repair_session_insights(_config(tmp_path), dry_run=False) - - assert result.success is True - assert result.repaired_count == 0 - assert result.detail == "Session insights already ready" - - -def test_repair_session_insights_dry_run_reports_archive_wide_rebuild( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - class FakeArchive: - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return SessionInsightStatusSnapshot( - total_sessions=16_358, - profile_rows_ready=False, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=True, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - threads_ready=True, - tag_rollups_ready=True, - missing_profile_row_count=103, - ) - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - - result = repair_mod.repair_session_insights(_config(tmp_path), dry_run=True) - - assert result.success is True - assert result.repaired_count == 16_358 - assert result.detail == ( - "Would: rebuild archive-wide session insights for 16,358 session(s) to repair 103 debt row(s)" - ) - - -def test_repair_session_insights_dry_run_reports_scoped_rebuild( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - class FakeArchive: - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return SessionInsightStatusSnapshot( - total_sessions=16_358, - profile_rows_ready=False, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=True, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - threads_ready=True, - tag_rollups_ready=True, - missing_profile_row_count=103, - ) - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - - result = repair_mod.repair_session_insights( - _config(tmp_path), - dry_run=True, - session_ids=("a", "b", "c"), - ) - - assert result.success is True - assert result.repaired_count == 3 - assert result.detail == "Would: rebuild session insights for 3 scoped session(s)" - - -def test_repair_session_insights_clears_scoped_convergence_debt( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - with sqlite3.connect(tmp_path / "ops.db") as conn: - conn.execute( - """ - CREATE TABLE convergence_debt ( - debt_id TEXT PRIMARY KEY, - stage TEXT NOT NULL, - target_type TEXT NOT NULL, - target_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'failed' CHECK(status IN ('failed', 'deferred')), - priority INTEGER NOT NULL DEFAULT 0, - attempts INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - next_retry_at TEXT, - materializer_version TEXT, - created_at_ms INTEGER NOT NULL, - updated_at_ms INTEGER NOT NULL, - UNIQUE(stage, target_type, target_id) - ) - """ - ) - conn.executemany( - """ - INSERT INTO convergence_debt ( - debt_id, stage, target_type, target_id, status, priority, - attempts, last_error, next_retry_at, materializer_version, - created_at_ms, updated_at_ms - ) - VALUES (?, ?, 'session_id', ?, 'deferred', 0, 1, 'quiet window', NULL, NULL, 1, 1) - """, - ( - ("debt-1", "insights", "codex-session:target"), - ("debt-2", "insights", "codex-session:other"), - ("debt-3", "fts", "codex-session:target"), - ), - ) - - class FakeArchive: - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return _ready_session_insight_status() - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - monkeypatch.setattr( - "polylogue.storage.insights.session.rebuild.rebuild_archive_session_insights", - lambda _archive, **_kwargs: SessionInsightCounts(profiles=1), - ) - - result = repair_mod.repair_session_insights( - _config(tmp_path), - dry_run=False, - session_ids=("codex-session:target",), - ) - - assert result.success is True - assert result.repaired_count == 1 - with sqlite3.connect(tmp_path / "ops.db") as conn: - rows = conn.execute( - """ - SELECT stage, target_id - FROM convergence_debt - ORDER BY debt_id - """ - ).fetchall() - - assert rows == [ - ("insights", "codex-session:other"), - ("fts", "codex-session:target"), - ] - - -def test_repair_session_insights_uses_candidate_session_ids(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - conn = sqlite3.connect(":memory:") - conn.row_factory = sqlite3.Row - conn.executescript( - """ - CREATE TABLE sessions (session_id TEXT PRIMARY KEY, sort_key_ms REAL, updated_at_ms INTEGER); - CREATE TABLE session_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT, - work_event_count INTEGER, - phase_count INTEGER - ); - CREATE TABLE session_latency_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT - ); - CREATE TABLE session_work_events (session_id TEXT); - CREATE TABLE session_phases (session_id TEXT); - CREATE TABLE insight_materialization ( - insight_type TEXT, - session_id TEXT, - materializer_version INTEGER, - source_sort_key_ms INTEGER - ); - """ - ) - conn.executemany( - "INSERT INTO sessions(session_id, sort_key_ms) VALUES (?, ?)", - (("ready", 1_000.0), ("missing", 2_000.0)), - ) - conn.execute( - """ - INSERT INTO session_profiles( - session_id, materializer_version, source_sort_key, work_event_count, phase_count - ) - VALUES ('ready', ?, 1.0, 0, 0) - """, - (repair_mod._session_insight_materializer_version(),), - ) - conn.execute( - """ - INSERT INTO session_latency_profiles(session_id, materializer_version, source_sort_key) - VALUES ('ready', ?, 1.0) - """, - (repair_mod._session_insight_materializer_version(),), - ) - conn.executemany( - """ - INSERT INTO insight_materialization( - insight_type, session_id, materializer_version, source_sort_key_ms - ) VALUES (?, 'ready', ?, 1000) - """, - ( - ("session_profile", repair_mod._session_insight_materializer_version()), - ("latency", repair_mod._session_insight_materializer_version()), - ("work_events", repair_mod._session_insight_materializer_version()), - ("phases", repair_mod._session_insight_materializer_version()), - ("thread", repair_mod._session_insight_materializer_version()), - ("runs", repair_mod._session_insight_materializer_version()), - ("observed_events", repair_mod._session_insight_materializer_version()), - ("context_snapshots", repair_mod._session_insight_materializer_version()), - ), - ) - - calls: list[tuple[str, ...] | None] = [] - - class FakeArchive: - _conn = conn - - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return next(statuses) - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - stale_status = SessionInsightStatusSnapshot( - total_sessions=2, - profile_rows_ready=False, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=True, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - threads_ready=True, - tag_rollups_ready=True, - missing_profile_row_count=1, - ) - statuses = iter((stale_status, _ready_session_insight_status())) - - def fake_rebuild(_archive: FakeArchive, *, session_ids: tuple[str, ...] | None, **_kwargs: object) -> Any: - calls.append(session_ids) - return SessionInsightCounts(profiles=1) - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - monkeypatch.setattr( - "polylogue.storage.insights.session.rebuild.rebuild_archive_session_insights", - fake_rebuild, - ) - - result = repair_mod.repair_session_insights(_config(tmp_path), dry_run=False) - - assert result.success is True - assert result.repaired_count == 1 - assert calls == [("missing",)] - - -def test_repair_session_insights_targets_stale_thread_materialization( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - conn = sqlite3.connect(":memory:") - conn.row_factory = sqlite3.Row - conn.executescript( - """ - CREATE TABLE sessions (session_id TEXT PRIMARY KEY, sort_key_ms REAL, updated_at_ms INTEGER); - CREATE TABLE session_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT, - work_event_count INTEGER, - phase_count INTEGER - ); - CREATE TABLE session_latency_profiles ( - session_id TEXT PRIMARY KEY, - materializer_version INTEGER, - source_sort_key REAL, - source_updated_at TEXT - ); - CREATE TABLE session_work_events (session_id TEXT); - CREATE TABLE session_phases (session_id TEXT); - CREATE TABLE insight_materialization ( - insight_type TEXT, - session_id TEXT, - materializer_version INTEGER, - source_sort_key_ms INTEGER - ); - """ - ) - conn.execute("INSERT INTO sessions(session_id, sort_key_ms) VALUES ('stale-thread-marker', 1000)") - current_version = repair_mod._session_insight_materializer_version() - conn.execute( - """ - INSERT INTO session_profiles( - session_id, materializer_version, source_sort_key, work_event_count, phase_count - ) - VALUES ('stale-thread-marker', ?, 1.0, 0, 0) - """, - (current_version,), - ) - conn.execute( - """ - INSERT INTO session_latency_profiles(session_id, materializer_version, source_sort_key) - VALUES ('stale-thread-marker', ?, 1.0) - """, - (current_version,), - ) - conn.executemany( - """ - INSERT INTO insight_materialization( - insight_type, session_id, materializer_version, source_sort_key_ms - ) VALUES (?, 'stale-thread-marker', ?, 1000) - """, - ( - ("session_profile", current_version), - ("latency", current_version), - ("work_events", current_version), - ("phases", current_version), - ("runs", current_version), - ("observed_events", current_version), - ("context_snapshots", current_version), - ("thread", current_version - 1), - ), - ) - - calls: list[tuple[str, tuple[str, ...] | None]] = [] - - class FakeArchive: - _conn = conn - - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return next(statuses) - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - stale_status = SessionInsightStatusSnapshot( - total_sessions=1, - profile_rows_ready=True, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=True, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - threads_ready=False, - tag_rollups_ready=True, - missing_thread_materialization_count=1, - ) - statuses = iter((stale_status, _ready_session_insight_status())) - - def fake_rebuild(_archive: FakeArchive, *, session_ids: tuple[str, ...] | None, **_kwargs: object) -> Any: - calls.append(("rebuild", session_ids)) - return SessionInsightCounts(threads=1) - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - monkeypatch.setattr( - "polylogue.storage.insights.session.rebuild.rebuild_archive_session_insights", - fake_rebuild, - ) - result = repair_mod.repair_session_insights(_config(tmp_path), dry_run=False) - - assert result.success is True - assert result.repaired_count == 1 - assert calls == [("rebuild", ("stale-thread-marker",))] - - def test_repair_assessment_ignores_optional_run_projection_cache_gaps() -> None: status = SessionInsightStatusSnapshot( total_sessions=1, @@ -4694,167 +3762,6 @@ def test_repair_assessment_ignores_optional_run_projection_cache_gaps() -> None: assert assessment.row_debt == 0 -def test_repair_session_insights_uses_stale_profile_candidates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - calls: list[tuple[str, tuple[str, ...] | None]] = [] - - class FakeArchive: - def session_insight_status(self) -> SessionInsightStatusSnapshot: - return next(statuses) - - def __enter__(self) -> FakeArchive: - return self - - def __exit__(self, *_args: object) -> None: - pass - - stale_status = SessionInsightStatusSnapshot( - profile_rows_ready=False, - latency_profile_rows_ready=True, - work_event_inference_rows_ready=False, - work_event_inference_fts_ready=True, - phase_inference_rows_ready=True, - threads_ready=True, - tag_rollups_ready=True, - stale_profile_row_count=2, - stale_work_event_inference_count=2, - work_event_inference_fts_count=4, - work_event_inference_count=4, - thread_count=1, - ) - statuses = iter((stale_status, _ready_session_insight_status())) - - def fake_rebuild(_archive: FakeArchive, *, session_ids: tuple[str, ...] | None, **_kwargs: object) -> Any: - calls.append(("rebuild", session_ids)) - return SessionInsightCounts(profiles=2, work_events=2) - - monkeypatch.setattr( - "polylogue.storage.sqlite.archive_tiers.archive.ArchiveStore.open_existing", - lambda _archive_root, read_only=False: FakeArchive(), - ) - monkeypatch.setattr( - "polylogue.storage.insights.session.rebuild.rebuild_archive_session_insights", - fake_rebuild, - ) - - result = repair_mod.repair_session_insights(_config(tmp_path), dry_run=False) - - assert result.success is True - assert result.repaired_count == 4 - assert ("rebuild", None) in calls - - -def test_offline_maintenance_refuses_live_daemon(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr("polylogue.maintenance.offline_guard.running_daemon_pid", lambda _config: 1234) - - results = repair_mod.run_selected_maintenance( - _config(tmp_path), - repair=True, - cleanup=False, - targets=("session_insights",), - ) - - assert len(results) == 1 - assert results[0].name == "session_insights" - assert results[0].success is False - assert "polylogued PID 1234 is running" in results[0].detail - - -def test_offline_maintenance_preview_allowed_with_live_daemon(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setattr("polylogue.maintenance.offline_guard.running_daemon_pid", lambda _config: 1234) - - results = repair_mod.run_selected_maintenance( - _config(tmp_path), - repair=True, - cleanup=False, - dry_run=True, - preview_counts={"session_insights": 2}, - targets=("session_insights",), - ) - - assert len(results) == 1 - assert results[0].success is True - assert results[0].repaired_count == 2 - - -def test_selected_cleanup_passes_session_scope_to_empty_session_handler( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - seen: list[tuple[str, ...] | None] = [] - - def empty_sessions( - _config: Config, dry_run: bool, *, session_ids: tuple[str, ...] | None = None - ) -> repair_mod.RepairResult: - del dry_run - seen.append(session_ids) - return repair_mod.RepairResult( - name="empty_sessions", - category=MaintenanceCategory.ARCHIVE_CLEANUP, - destructive=True, - repaired_count=1, - success=True, - detail="scoped", - ) - - monkeypatch.setattr(repair_mod, "offline_maintenance_blockers", lambda *_args, **_kwargs: []) - monkeypatch.setitem(repair_mod.REPAIR_HANDLERS, "empty_sessions", empty_sessions) - - results = repair_mod.run_selected_maintenance( - _config(tmp_path), - repair=False, - cleanup=True, - targets=("empty_sessions",), - scope_filter=MaintenanceScopeFilter(session_ids=("s-1", "s-2")), - ) - - assert [result.name for result in results] == ["empty_sessions"] - assert seen == [("s-1", "s-2")] - - -@pytest.mark.parametrize( - ("repair", "cleanup", "target", "scope_filter"), - [ - (True, False, "session_insights", MaintenanceScopeFilter(origin="codex-session")), - (False, True, "superseded_raw_snapshots", MaintenanceScopeFilter(session_ids=("s-1",))), - ], -) -def test_selected_maintenance_does_not_expand_rejected_target_to_all_dispatch( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - repair: bool, - cleanup: bool, - target: str, - scope_filter: MaintenanceScopeFilter, -) -> None: - """A refused explicit target cannot fall back to an unscoped run.""" - - def rejected_target(*_args: object, **_kwargs: object) -> repair_mod.RepairResult: - raise AssertionError("rejected target was dispatched") - - monkeypatch.setattr(repair_mod, "offline_maintenance_blockers", lambda *_args, **_kwargs: []) - monkeypatch.setitem(repair_mod.REPAIR_HANDLERS, target, rejected_target) - - results = repair_mod.run_selected_maintenance( - _config(tmp_path), - repair=repair, - cleanup=cleanup, - targets=(target,), - scope_filter=scope_filter, - ) - - assert [(result.name, result.success) for result in results] == [(target, False)] - assert "Unsupported scope dimensions" in results[0].detail - - -# polylogue-t93b: the daemon whale pass. A component whose aggregate raw -# payload exceeds the ordinary fast-path envelope must not stay -# permanently resource-blocked when every member is stream-record-safe -- -# ``raw_materialization_whale_pass_candidate`` selects it for a dedicated, -# single-component pass at a widened envelope, and the very same -# ``repair_raw_materialization`` entrypoint (now ``raw_artifact_id``-scoped) -# converges it. Non-stream-safe oversized components must never be -# selected and must carry a distinct typed reason instead. - - def test_raw_materialization_whale_pass_candidate_selects_stream_safe_blocked_component(tmp_path: Path) -> None: from tests.infra.revision_backfill_benchmark import build_revision_chain_corpus @@ -4864,7 +3771,7 @@ def test_raw_materialization_whale_pass_candidate_selects_stream_safe_blocked_co # Envelope wide enough to admit the whole chain: nothing is oversized, # no escalation needed. assert ( - repair_mod.raw_materialization_whale_pass_candidate( + raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=10_000_000, whale_max_payload_bytes=10_000_000 ) is None @@ -4873,14 +3780,14 @@ def test_raw_materialization_whale_pass_candidate_selects_stream_safe_blocked_co # Ordinary envelope too small for the chain, but the whale envelope is # wide enough and every member is stream-safe (Provider.CODEX, .jsonl): # the earliest-acquired raw (the fairness seed) is selected. - seed = repair_mod.raw_materialization_whale_pass_candidate( + seed = raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=500, whale_max_payload_bytes=10_000_000 ) assert seed == raw_ids[0] # Whale envelope also too small: genuinely still blocked, no candidate. assert ( - repair_mod.raw_materialization_whale_pass_candidate( + raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=500, whale_max_payload_bytes=500 ) is None @@ -4893,7 +3800,7 @@ def test_stream_safe_resolves_non_candidate_component_members_via_expanded_maps( maps. Stream-safety must resolve them there, not read origin=None and judge a fully stream-safe codex component non-safe -- the bug that made the daemon whale pass skip the 6.33GB codex witness component (polylogue-t93b).""" - candidates = repair_mod.RawMaterializationCandidates( + candidates = raw_convergence_mod.RawMaterializationCandidates( raw_ids=["cand"], missing_blobs=0, already_parsed=0, @@ -4903,10 +3810,10 @@ def test_stream_safe_resolves_non_candidate_component_members_via_expanded_maps( expanded_source_paths={"cand": "/c/rollout-2026-a.jsonl", "noncand": "/c/rollout-2026-b.jsonl"}, ) # Candidate member: stream-safe as before. - assert repair_mod._raw_materialization_stream_safe(candidates, "cand") is True + assert raw_convergence_mod._raw_materialization_stream_safe(candidates, "cand") is True # Non-candidate member present ONLY in the expanded maps must ALSO be # judged by its real codex origin -- True, not False from a missing lookup. - assert repair_mod._raw_materialization_stream_safe(candidates, "noncand") is True + assert raw_convergence_mod._raw_materialization_stream_safe(candidates, "noncand") is True def test_raw_materialization_whale_pass_candidate_excludes_non_stream_safe_component(tmp_path: Path) -> None: @@ -4929,7 +3836,7 @@ def test_raw_materialization_whale_pass_candidate_excludes_non_stream_safe_compo # ChatGPT export + non-jsonl source path is not stream-record-safe -- # must never be selected for escalation, at any whale envelope width. assert ( - repair_mod.raw_materialization_whale_pass_candidate( + raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=500, whale_max_payload_bytes=1_000_000_000 ) is None @@ -4969,10 +3876,10 @@ def test_raw_materialization_ordinary_pass_census_detail_distinguishes_escalatio ) conn.commit() - stream_safe_result = repair_mod.repair_raw_materialization( + stream_safe_result = raw_convergence_mod.converge_raw_materialization( _config(tmp_path), raw_artifact_id=stream_safe_raw_id, max_payload_bytes=ordinary_limit ) - non_stream_safe_result = repair_mod.repair_raw_materialization( + non_stream_safe_result = raw_convergence_mod.converge_raw_materialization( _config(tmp_path), raw_artifact_id=non_stream_safe_raw_id, max_payload_bytes=ordinary_limit ) @@ -5011,8 +3918,12 @@ def test_non_stream_safe_envelope_terminal_never_reports_deferred_success(tmp_pa conn.execute("UPDATE raw_sessions SET blob_size = 501 WHERE raw_id = ?", (raw_id,)) conn.commit() - first = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_id=raw_id, max_payload_bytes=500) - second = repair_mod.repair_raw_materialization(_config(tmp_path), raw_artifact_id=raw_id, max_payload_bytes=500) + first = raw_convergence_mod.converge_raw_materialization( + _config(tmp_path), raw_artifact_id=raw_id, max_payload_bytes=500 + ) + second = raw_convergence_mod.converge_raw_materialization( + _config(tmp_path), raw_artifact_id=raw_id, max_payload_bytes=500 + ) assert first.success is False # Removing the terminal/deferred distinction from the envelope query makes @@ -5040,12 +3951,12 @@ def test_raw_materialization_whale_pass_converges_blocked_component_to_resolved_ whale_limit = 10_000_000 # The ordinary fast-path envelope permanently blocks the whole chain. - blocked = repair_mod.repair_raw_materialization( + blocked = raw_convergence_mod.converge_raw_materialization( config, raw_artifact_id=raw_ids[0], max_payload_bytes=ordinary_limit ) assert blocked.success is False - seed = repair_mod.raw_materialization_whale_pass_candidate( + seed = raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=ordinary_limit, whale_max_payload_bytes=whale_limit ) assert seed is not None @@ -5078,7 +3989,7 @@ def counting_pop(cache: RawParsePrefetchCache, raw_id: str) -> object: assert stage.cache.contains(seed) with pytest.MonkeyPatch.context() as patch: patch.setattr(RawParsePrefetchCache, "pop", counting_pop) - converged = repair_mod.repair_raw_materialization( + converged = raw_convergence_mod.converge_raw_materialization( config, raw_artifact_id=seed, max_payload_bytes=whale_limit, @@ -5102,7 +4013,7 @@ def counting_pop(cache: RawParsePrefetchCache, raw_id: str) -> object: # Fully converged: no longer a whale-pass candidate at any envelope. assert ( - repair_mod.raw_materialization_whale_pass_candidate( + raw_convergence_mod.raw_materialization_whale_pass_candidate( config, ordinary_max_payload_bytes=ordinary_limit, whale_max_payload_bytes=whale_limit ) is None @@ -5140,7 +4051,7 @@ def counting_commit(self: ArchiveStore) -> None: original_commit(self) with mock.patch.object(ArchiveStore, "commit", counting_commit): - result = repair_mod.repair_raw_materialization( + result = raw_convergence_mod.converge_raw_materialization( _config(root), raw_artifact_id=raw_ids[0], max_payload_bytes=whale_limit, @@ -5178,7 +4089,7 @@ def test_raw_materialization_converges_component_with_byte_governed_append_fragm (767 append fragments, 20 cleanly parsed fulls, ~20k messages) has sat unmaterialized in exactly this state. - Drives the real ``repair_raw_materialization`` entry point, not the + Drives the real ``converge_raw_materialization`` entry point, not the census helper directly, because the livelock is a property of the pass's census/planning handshake rather than of either half alone. """ @@ -5245,11 +4156,11 @@ def test_raw_materialization_converges_component_with_byte_governed_append_fragm store.commit() config = _config(tmp_path) - result = repair_mod.repair_raw_materialization(config) + result = raw_convergence_mod.converge_raw_materialization(config) for _ in range(2): if result.repaired_count: break - result = repair_mod.repair_raw_materialization(config) + result = raw_convergence_mod.converge_raw_materialization(config) with sqlite3.connect(tmp_path / "source.db") as conn: append_receipt = conn.execute( diff --git a/tests/unit/storage/test_rebuild_complexity.py b/tests/unit/storage/test_rebuild_complexity.py index 1353d12cf2..cbb9297200 100644 --- a/tests/unit/storage/test_rebuild_complexity.py +++ b/tests/unit/storage/test_rebuild_complexity.py @@ -14,7 +14,7 @@ from polylogue.config import Config from polylogue.core.enums import Provider from polylogue.sources import revision_backfill -from polylogue.storage import repair as repair_mod +from polylogue.storage import raw_convergence as raw_convergence_mod from polylogue.storage.sqlite.archive_tiers.archive import ArchiveStore from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root from tests.infra.growth_budgets import GrowthBudget, GrowthObservation, evaluate_growth_budgets @@ -86,7 +86,7 @@ def _seed_raw_archive(root: Path, count: int, *, prefix: str = "session") -> lis def _materialize_all(root: Path, raw_count: int) -> None: - result = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=raw_count) + result = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=raw_count) assert result.success is True assert result.repaired_count == raw_count @@ -94,7 +94,7 @@ def _materialize_all(root: Path, raw_count: int) -> None: def _quiesce_census(root: Path, *, limit: int) -> None: config = _config(root) for _ in range(100): - result = repair_mod.repair_raw_materialization(config, dry_run=True, raw_artifact_limit=limit) + result = raw_convergence_mod.converge_raw_materialization(config, dry_run=True, raw_artifact_limit=limit) assert result.census_receipt is not None if result.census_receipt.quiescent: return @@ -129,7 +129,7 @@ def _run_component_measurement( if install_mutation is not None: install_mutation(mutation) with sqlite_work_counter(step_interval=1) as counter: - result = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=component_count) + result = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=component_count) assert result.repaired_count == component_count return GrowthObservation( @@ -249,7 +249,7 @@ def counted_backfill(*args: Any, _original: Any = original_backfill, **kwargs: A with monkeypatch.context() as mutation: mutation.setattr(revision_backfill, "backfill_historical_revision_evidence", counted_backfill) - result = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=batch_size) + result = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=batch_size) assert result.metrics["raw_materialization_executed_count"] == float(batch_size) assert result.metrics["raw_materialization_scanned_raw_count"] <= float(batch_size) @@ -276,7 +276,7 @@ def test_mixed_hot_cold_large_small_components_all_receive_a_turn( with sqlite3.connect(root / "source.db") as conn: conn.execute( "UPDATE raw_sessions SET blob_size = ? WHERE raw_id = ?", - (repair_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES // 2, large_raw_id), + (raw_convergence_mod.RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES // 2, large_raw_id), ) conn.commit() _quiesce_census(root, limit=1) @@ -296,7 +296,7 @@ def fail_hot_component(*args: Any, **kwargs: Any) -> Any: mutation.setattr(revision_backfill, "backfill_historical_revision_evidence", fail_hot_component) selected: list[str] = [] for _ in range(3): - result = repair_mod.repair_raw_materialization(_config(root), raw_artifact_limit=1) + result = raw_convergence_mod.converge_raw_materialization(_config(root), raw_artifact_limit=1) assert len(result.plan_outcomes) == 1 selected.append(result.plan_outcomes[0].input_raw_ids[0]) @@ -318,7 +318,7 @@ def test_progress_counter_is_monotonic_and_resumable_across_bounded_passes(tmp_p selected: list[str] = [] config = _config(root) for _ in range(4): - result = repair_mod.repair_raw_materialization(config, raw_artifact_limit=2) + result = raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=2) remaining.append(int(result.metrics["raw_materialization_remaining_candidate_count"])) repaired.append(result.repaired_count) selected.extend( @@ -331,4 +331,4 @@ def test_progress_counter_is_monotonic_and_resumable_across_bounded_passes(tmp_p assert repaired == [2, 2, 2, 1] assert len(selected) == raw_count assert len(set(selected)) == raw_count - assert repair_mod.repair_raw_materialization(config, raw_artifact_limit=2).success is True + assert raw_convergence_mod.converge_raw_materialization(config, raw_artifact_limit=2).success is True diff --git a/tests/unit/storage/test_schema_policy_contracts.py b/tests/unit/storage/test_schema_policy_contracts.py index ad0313da21..a3de812bb7 100644 --- a/tests/unit/storage/test_schema_policy_contracts.py +++ b/tests/unit/storage/test_schema_policy_contracts.py @@ -379,7 +379,7 @@ def test_version_mismatch_message_distinguishes_newer_and_older() -> None: assert str(SCHEMA_VERSION) in older assert newer != older assert "newer" in newer.lower() - assert "polylogue ops maintenance rebuild-index" in older + assert "Reset the derived index and let `polylogued run` rebuild it from source." in older def test_decision_for_unknown_version_is_explicit_mismatch(tmp_path: Path) -> None: diff --git a/tests/unit/test_sqlite_connection_hygiene.py b/tests/unit/test_sqlite_connection_hygiene.py index 6773361d69..927fe59d92 100644 --- a/tests/unit/test_sqlite_connection_hygiene.py +++ b/tests/unit/test_sqlite_connection_hygiene.py @@ -119,30 +119,3 @@ def test_index_generation_checkpoint_truncate_closes_connection( captured = _capture_connections(monkeypatch, "polylogue.storage.index_generation") _checkpoint_truncate(db_path, label="test-checkpoint") _assert_all_closed(captured) - - -def test_archive_readiness_active_rebuild_attempts_closes_connection( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - from polylogue.storage.archive_readiness import active_rebuild_index_attempts - - ops_db = tmp_path / "ops.db" - conn = sqlite3.connect(ops_db) - try: - conn.execute( - """ - CREATE TABLE ingest_attempts ( - attempt_id TEXT, phase TEXT, status TEXT, - started_at_ms INTEGER, heartbeat_at_ms INTEGER, - parsed_raw_count INTEGER, materialized_count INTEGER - ) - """ - ) - conn.commit() - finally: - conn.close() - - captured = _capture_connections(monkeypatch, "polylogue.storage.archive_readiness") - result = active_rebuild_index_attempts(ops_db) - assert result == [] - _assert_all_closed(captured) From 0bd70ef369280a04c3297e4b47acc59f3654387e Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 18:16:02 +0200 Subject: [PATCH 06/11] chore: close the retired surfaces after the master merge Post-merge follow-through for the repair/rebuild deletion. The archive-debt operation reads the blob limit from storage/raw_convergence. The layering baseline ratchets 297 -> 288: entries for deleted files go, and the two status readers' baselined edge follows the storage/repair rename. The stuck- source runbook points at commands that exist. Tests pin the retirement: the maintenance MCP tool neither declares nor dispatches preview/execute/status/ list, and the nine retired `ops maintenance` verbs fail discovery. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- docs/maintenance.md | 8 +-- docs/openapi/search.yaml | 38 ------------- docs/plans/layering-surface-baseline.json | 49 +---------------- polylogue/analysis/readiness.py | 3 +- polylogue/storage/raw_convergence.py | 6 --- .../unit/cli/test_archive_maintenance_cli.py | 40 ++++++++++++++ tests/unit/mcp/test_tool_discovery.py | 54 ++++++++++++++++++- tests/unit/operations/test_archive_debt.py | 2 +- tests/unit/pipeline/test_ingest_batch.py | 2 +- tests/unit/storage/test_raw_admission.py | 2 +- tests/unit/storage/test_raw_convergence.py | 3 -- 11 files changed, 103 insertions(+), 104 deletions(-) diff --git a/docs/maintenance.md b/docs/maintenance.md index aa5c90b14d..4efd258ef5 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -623,15 +623,15 @@ expected mid-rebuild noise or a real regression — do not silently retry. ### Investigating a stuck source **Symptoms.** A source family stops producing new sessions even -though source files are present. `polylogue sources` shows a source -with stale `last_seen`. Daemon logs show repeated parse errors for -the same artifact id. +though source files are present. `polylogue ops status --json` reports +stale ingestion progress for the source. Daemon logs show repeated parse +errors for the same artifact id. **Recovery.** ```bash # 1. Identify the stuck source. -polylogue sources --output-format json | jq '.[] | select(.healthy==false)' +polylogue ops status --json | jq '.components[]? | select(.state != "ready")' # 2. Inspect raw-artifact failures from that source. polylogue ops diagnostics workload --json \ diff --git a/docs/openapi/search.yaml b/docs/openapi/search.yaml index c6753a8ee4..5132b37151 100644 --- a/docs/openapi/search.yaml +++ b/docs/openapi/search.yaml @@ -4832,20 +4832,6 @@ x-polylogue-route-contracts: auth_policy: bearer_if_configured_and_same_origin response_contract: MutationResultPayload notes: Local CLI transport; consumes one daemon-held authorization under the writer gate. -- method: POST - pattern: /api/maintenance/rebuild-index - kind: maintenance - stability: operational - auth_policy: bearer_if_configured_and_same_origin - response_contract: RebuildIndexReceipt - notes: Runs exactly one source snapshot replay through the daemon write coordinator. -- method: POST - pattern: /api/maintenance/discard-index-candidate - kind: maintenance - stability: private - auth_policy: bearer_if_configured_and_same_origin - response_contract: inactive candidate discard receipt - notes: Reclaims one generation only when its immutable owner id still identifies an inactive candidate. - method: GET pattern: /api/facets kind: read_query @@ -5095,18 +5081,6 @@ x-polylogue-route-contracts: stability: stable auth_policy: credential_if_configured response_contract: workspace JSON -- method: GET - pattern: /api/maintenance/operations - kind: maintenance - stability: stable - auth_policy: credential_if_configured - response_contract: maintenance operations JSON -- method: GET - pattern: /api/maintenance/status/:id - kind: maintenance - stability: stable - auth_policy: credential_if_configured - response_contract: maintenance operation status JSON - method: POST pattern: /api/telemetry/mcp-calls kind: operational @@ -5134,18 +5108,6 @@ x-polylogue-route-contracts: response_contract: demo augmentation result JSON notes: Applies deterministic demo writes through the write bridge; exists for the demo archive, not for general archive mutation. -- method: POST - pattern: /api/maintenance/plan - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: maintenance operation preview -- method: POST - pattern: /api/maintenance/run - kind: maintenance - stability: stable - auth_policy: bearer_if_configured_and_same_origin - response_contract: maintenance operation result - method: POST pattern: /api/user/marks kind: user_overlay diff --git a/docs/plans/layering-surface-baseline.json b/docs/plans/layering-surface-baseline.json index 91ecad812e..5d877a56dd 100644 --- a/docs/plans/layering-surface-baseline.json +++ b/docs/plans/layering-surface-baseline.json @@ -1,9 +1,4 @@ [ - { - "target": "polylogue", - "file": "polylogue/cli/click_app.py", - "import": "devtools.checkout_guard" - }, { "target": "polylogue/api", "file": "polylogue/api/__init__.py", @@ -404,11 +399,6 @@ "file": "polylogue/cli/commands/maintenance/_migrate_tier.py", "import": "polylogue.storage.sqlite.migration_runner" }, - { - "target": "polylogue/cli", - "file": "polylogue/cli/commands/maintenance/_rebuild_index.py", - "import": "polylogue.storage.archive_identity" - }, { "target": "polylogue/cli", "file": "polylogue/cli/commands/paths.py", @@ -467,7 +457,7 @@ { "target": "polylogue/cli", "file": "polylogue/cli/commands/status.py", - "import": "polylogue.storage.repair" + "import": "polylogue.storage.raw_convergence" }, { "target": "polylogue/cli", @@ -539,31 +529,16 @@ "file": "polylogue/cli/select.py", "import": "polylogue.storage.archive_identity" }, - { - "target": "polylogue/cli", - "file": "polylogue/cli/shared/check_maintenance.py", - "import": "polylogue.storage.repair" - }, { "target": "polylogue/cli", "file": "polylogue/cli/shared/check_models.py", "import": "polylogue.storage.artifacts.views" }, - { - "target": "polylogue/cli", - "file": "polylogue/cli/shared/check_models.py", - "import": "polylogue.storage.repair" - }, { "target": "polylogue/cli", "file": "polylogue/cli/shared/check_models.py", "import": "polylogue.storage.runtime" }, - { - "target": "polylogue/cli", - "file": "polylogue/cli/shared/check_support.py", - "import": "polylogue.storage.sqlite.connection" - }, { "target": "polylogue/cli", "file": "polylogue/cli/shared/check_workflow.py", @@ -574,11 +549,6 @@ "file": "polylogue/cli/shared/check_workflow.py", "import": "polylogue.storage.blob_integrity" }, - { - "target": "polylogue/cli", - "file": "polylogue/cli/shared/check_workflow.py", - "import": "polylogue.storage.repair" - }, { "target": "polylogue/cli", "file": "polylogue/cli/shared/embed_runtime.py", @@ -659,16 +629,6 @@ "file": "polylogue/daemon/blob_integrity_alerts.py", "import": "polylogue.storage.blob_integrity" }, - { - "target": "polylogue/daemon", - "file": "polylogue/daemon/bulk_rebuild.py", - "import": "polylogue.storage.archive_identity" - }, - { - "target": "polylogue/daemon", - "file": "polylogue/daemon/bulk_rebuild.py", - "import": "polylogue.storage.index_generation" - }, { "target": "polylogue/daemon", "file": "polylogue/daemon/catchup_status.py", @@ -1294,11 +1254,6 @@ "file": "polylogue/daemon/metrics.py", "import": "polylogue.storage.archive_layout" }, - { - "target": "polylogue/daemon", - "file": "polylogue/daemon/metrics.py", - "import": "polylogue.storage.archive_readiness" - }, { "target": "polylogue/daemon", "file": "polylogue/daemon/metrics.py", @@ -1392,7 +1347,7 @@ { "target": "polylogue/daemon", "file": "polylogue/daemon/status.py", - "import": "polylogue.storage.repair" + "import": "polylogue.storage.raw_convergence" }, { "target": "polylogue/daemon", diff --git a/polylogue/analysis/readiness.py b/polylogue/analysis/readiness.py index d48693573e..afd3411c67 100644 --- a/polylogue/analysis/readiness.py +++ b/polylogue/analysis/readiness.py @@ -7,9 +7,8 @@ from pydantic import Field from polylogue.analysis.archive_models import ARCHIVE_INSIGHT_CONTRACT_VERSION, ArchiveInsightModel -from polylogue.maintenance.targets import build_maintenance_target_catalog -_REPAIR_HINT = build_maintenance_target_catalog().repair_hint(("session_insights",), include_run_all=True) +_REPAIR_HINT = "Run `polylogued run`." class InsightReadinessQuery(ArchiveInsightModel): diff --git a/polylogue/storage/raw_convergence.py b/polylogue/storage/raw_convergence.py index af7b8bd840..f68dd2fa29 100644 --- a/polylogue/storage/raw_convergence.py +++ b/polylogue/storage/raw_convergence.py @@ -69,7 +69,6 @@ validate_raw_replay_application_receipt, validate_raw_replay_plan, ) -from polylogue.storage.runtime import SESSION_INSIGHT_MATERIALIZER_VERSION from polylogue.storage.sqlite.archive_tiers.source_write import ( ReconstructedRawRow, insert_reconstructed_raw_row, @@ -6148,8 +6147,3 @@ def _pass_deadline_exceeded() -> bool: plan_outcomes=plan_outcomes, census_receipt=census_receipt, ) - - -# --------------------------------------------------------------------------- -# Orchestration (run_safe_repairs, run_archive_cleanup, run_selected_maintenance) -# --------------------------------------------------------------------------- diff --git a/tests/unit/cli/test_archive_maintenance_cli.py b/tests/unit/cli/test_archive_maintenance_cli.py index 36f84477aa..f13b172611 100644 --- a/tests/unit/cli/test_archive_maintenance_cli.py +++ b/tests/unit/cli/test_archive_maintenance_cli.py @@ -3571,3 +3571,43 @@ def fail_or_race( else: assert not audit.exists() assert (root / ".maintenance-state" / "durable-change-trains" / "audit-adoption.json").is_file() + + +#: Verbs the manual rebuild engine and the generic repair product owned. They +#: were deleted with those products, so the CLI must not resolve them. +_RETIRED_MAINTENANCE_VERBS = ( + "rebuild-index", + "rebuild-index-status", + "reindex-canary", + "plan", + "run", + "run-preview", + "preview", + "status", + "blob-reference-closure", +) + + +def test_retired_maintenance_verbs_are_absent_from_the_command_inventory() -> None: + """Generated docs and shell completion must not rediscover the retired verbs. + + Anti-vacuity: re-registering any retired verb in + ``polylogue.cli.commands.maintenance`` turns this red. + """ + paths = {item.path for item in iter_command_paths(cli, include_root=False)} + + resurrected = sorted(verb for verb in _RETIRED_MAINTENANCE_VERBS if ("ops", "maintenance", verb) in paths) + assert not resurrected, f"retired maintenance verbs back in the inventory: {resurrected}" + + +@pytest.mark.parametrize("verb", _RETIRED_MAINTENANCE_VERBS) +def test_retired_maintenance_verb_fails_discovery(verb: str, cli_runner: CliRunner) -> None: + """Invoking a retired verb is a usage error, not a silent no-op run. + + Anti-vacuity: a compatibility shim that accepts the verb and exits 0 turns + this red. + """ + result = cli_runner.invoke(cli, ["ops", "maintenance", verb, "--help"]) + + assert result.exit_code != 0 + assert "No such command" in result.output diff --git a/tests/unit/mcp/test_tool_discovery.py b/tests/unit/mcp/test_tool_discovery.py index 7a7d69fe8f..14e285bccc 100644 --- a/tests/unit/mcp/test_tool_discovery.py +++ b/tests/unit/mcp/test_tool_discovery.py @@ -17,7 +17,7 @@ import pytest from polylogue.mcp.server import build_server -from tests.infra.mcp import EXPECTED_MINIMAL_ARGUMENTS, MCPServerUnderTest, invoke_surface +from tests.infra.mcp import ALL_CAPABILITIES, EXPECTED_MINIMAL_ARGUMENTS, MCPServerUnderTest, invoke_surface #: Synthetic session id for tools that require one — we accept not_found. _SYNTHETIC_CONV_ID = "test:conv-discovery-nonexistent" @@ -120,3 +120,55 @@ def test_read_tools_have_known_minimal_kwargs() -> None: f" - {t}" for t in uncovered ) pytest.fail(msg) + + +#: Operations the generic repair product owned. They were deleted with it, so the +#: ``maintenance`` tool must neither declare nor dispatch them. +_RETIRED_MAINTENANCE_OPERATIONS = frozenset({"preview", "execute", "status", "list"}) + + +def test_retired_maintenance_operations_are_not_declared() -> None: + """The maintenance tool's declared operations exclude the retired repair verbs. + + Anti-vacuity: re-adding any of ``preview``/``execute``/``status``/``list`` + to the ``operation`` Literal in + ``server_cutover.register_cutover_privileged_tools`` turns this red. + """ + import inspect + import typing + + server = cast(MCPServerUnderTest, build_server(capabilities=ALL_CAPABILITIES)) + tool = server._tool_manager._tools["maintenance"] + annotation = inspect.signature(tool.fn).parameters["operation"].annotation + declared = set(typing.get_args(annotation)) + + assert declared + assert not (declared & _RETIRED_MAINTENANCE_OPERATIONS), ( + f"retired maintenance operations re-declared: {sorted(declared & _RETIRED_MAINTENANCE_OPERATIONS)}" + ) + + +@pytest.mark.parametrize("operation", sorted(_RETIRED_MAINTENANCE_OPERATIONS)) +def test_retired_maintenance_operations_fail_dispatch(operation: str) -> None: + """Dispatching a retired operation is a typed refusal, never a partial run. + + Anti-vacuity: restoring a ``preview``/``execute``/``status``/``list`` branch + in ``_dispatch_maintenance`` returns a result payload instead of + ``invalid_argument``. + """ + import asyncio + + from polylogue.mcp.server_cutover import _dispatch_maintenance + + class _Hooks: + def get_config(self) -> object: + return object() + + def error_json(self, message: str, *, code: str) -> str: + return json.dumps({"error": message, "code": code}) + + payload = json.loads( + asyncio.run(_dispatch_maintenance(cast("object", _Hooks()), operation=operation, kwargs={})) # type: ignore[arg-type] + ) + assert payload["code"] == "invalid_argument" + assert "unknown maintenance operation" in payload["error"] diff --git a/tests/unit/operations/test_archive_debt.py b/tests/unit/operations/test_archive_debt.py index 42a62e3651..2f5d90f440 100644 --- a/tests/unit/operations/test_archive_debt.py +++ b/tests/unit/operations/test_archive_debt.py @@ -11,7 +11,7 @@ from polylogue.operations import archive_debt as module from polylogue.operations.archive_debt import archive_debt_list -from polylogue.storage.repair import RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES +from polylogue.storage.raw_convergence import RAW_MATERIALIZATION_EXECUTE_BLOB_LIMIT_BYTES from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS, initialize_archive_tier from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier from polylogue.storage.sqlite.archive_tiers.user_write import AssertionKind, upsert_assertion diff --git a/tests/unit/pipeline/test_ingest_batch.py b/tests/unit/pipeline/test_ingest_batch.py index 8bcc01b7f6..ad4049dfa0 100644 --- a/tests/unit/pipeline/test_ingest_batch.py +++ b/tests/unit/pipeline/test_ingest_batch.py @@ -2583,7 +2583,7 @@ def test_write_session_still_refuses_a_different_raw_than_the_accepted_head(tmp_ def test_write_session_refuses_when_a_parallel_head_accepts_a_different_raw(tmp_path: Path) -> None: """P2 bot finding on PR #3527: with more than one ``raw_revision_heads`` row for the same ``session_id`` (historical drift or an interrupted - repair -- ``storage/repair.py``'s ``parallel_session_heads`` shape), the + repair -- ``storage/raw_convergence.py``'s ``parallel_session_heads`` shape), the refusal decision must not depend on which row a bare ``LIMIT 1`` happened to select. Two heads for this session: one already accepts the incoming raw, the other accepts a different raw -- the write must diff --git a/tests/unit/storage/test_raw_admission.py b/tests/unit/storage/test_raw_admission.py index 4fccd45605..223b8721e3 100644 --- a/tests/unit/storage/test_raw_admission.py +++ b/tests/unit/storage/test_raw_admission.py @@ -732,7 +732,7 @@ def test_admit_raw_observation_rejects_grouped_with_prior_head(tmp_path: Path) - def test_insert_reconstructed_raw_row_preserves_byte_proven_repair_authority(tmp_path: Path) -> None: """polylogue-1fijp: the copy-forward exemption keeps the authority it was given. - ``storage/repair.py``'s browser-origin copy-forward rebuilds a raw row from + ``storage/raw_convergence.py``'s browser-origin copy-forward rebuilds a raw row from a plan that already proved the bytes. Routing it through ``admit_raw_observation`` instead would resolve BASELINE/``asserted`` against the absent prior head for the corrected logical key and silently diff --git a/tests/unit/storage/test_raw_convergence.py b/tests/unit/storage/test_raw_convergence.py index fdd97ee488..2de23ab41a 100644 --- a/tests/unit/storage/test_raw_convergence.py +++ b/tests/unit/storage/test_raw_convergence.py @@ -22,7 +22,6 @@ from polylogue.storage.blob_store import BlobStore from polylogue.storage.raw.models import RawSessionStateUpdate from polylogue.storage.raw_authority import RawReplayPlan, RawReplayPlanOutcome, RawReplayPlanStatus -from polylogue.storage.runtime import SESSION_INSIGHT_MATERIALIZER_VERSION from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root, initialize_archive_database from polylogue.storage.sqlite.archive_tiers.source_write import ArchiveSourceArtifact, upsert_raw_artifact from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier @@ -3739,8 +3738,6 @@ def __init__(self, **_kwargs: object) -> None: assert result.metrics["raw_materialization_already_parsed_count"] == 1.0 - - def test_raw_materialization_whale_pass_candidate_selects_stream_safe_blocked_component(tmp_path: Path) -> None: from tests.infra.revision_backfill_benchmark import build_revision_chain_corpus From e1f820f9493cd653ee8a14608d8f129f7b9b390f Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 18:41:03 +0200 Subject: [PATCH 07/11] test: drop the proofs the deleted engines owned mypy resolves a missing attribute but silently ignores a missing module, so imports of the deleted modules survived every static gate. The Codex 804 proof keeps acquisition, append planning, raw authority and both converge_materialization passes, and reads the archive convergence built; its crash/resume/promotion half went with the engine that drove it. The maintenance CLI registration suite keeps operation-recovery, blob-conservation and blob-integrity. The single-basis cost backfill and the maintenance failure-sample status projection have no route left. operation_ids guarded a path lookup that no longer exists, and rebuild_receipt has no caller. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- polylogue/maintenance/operation_ids.py | 26 -- tests/infra/rebuild_receipt.py | 116 ----- tests/unit/cli/commands/test_status.py | 8 +- .../unit/cli/test_maintenance_registration.py | 123 ----- tests/unit/cli/test_status.py | 12 +- tests/unit/cost/test_contract_suite.py | 81 ---- tests/unit/maintenance/test_operation_ids.py | 48 -- .../scenarios/test_codex_804_live_proof.py | 427 ++---------------- 8 files changed, 36 insertions(+), 805 deletions(-) delete mode 100644 polylogue/maintenance/operation_ids.py delete mode 100644 tests/infra/rebuild_receipt.py delete mode 100644 tests/unit/maintenance/test_operation_ids.py diff --git a/polylogue/maintenance/operation_ids.py b/polylogue/maintenance/operation_ids.py deleted file mode 100644 index 727bd856a3..0000000000 --- a/polylogue/maintenance/operation_ids.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Validation for opaque maintenance operation identifiers.""" - -from __future__ import annotations - -import ntpath -from pathlib import Path - - -def validate_operation_id(value: object) -> str: - """Validate a caller-supplied operation ID before it reaches a path. - - Operation IDs are opaque names, not filesystem paths. Generation belongs - to the operation entry point, so ``None`` is deliberately invalid here. - """ - if not isinstance(value, str) or not value: - raise ValueError("operation_id must be a non-empty string") - if "\x00" in value: - raise ValueError("operation_id must not contain NUL") - if "/" in value or "\\" in value: - raise ValueError("operation_id must not contain path separators") - if value in {".", ".."} or Path(value).is_absolute() or ntpath.isabs(value): - raise ValueError("operation_id must be a relative opaque identifier") - return value - - -__all__ = ["validate_operation_id"] diff --git a/tests/infra/rebuild_receipt.py b/tests/infra/rebuild_receipt.py deleted file mode 100644 index dba69a256e..0000000000 --- a/tests/infra/rebuild_receipt.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Targeted schema-inference receipts for real rebuild-route tests.""" - -from __future__ import annotations - -import json -import sqlite3 -from datetime import UTC, datetime -from pathlib import Path - -from polylogue.maintenance import schema_inference_gate as gate -from polylogue.sources.revision_backfill import census_historical_revision_evidence -from polylogue.storage.archive_identity import ArchiveIdentity, ArchiveLocation -from polylogue.storage.blob_store import BlobStore -from polylogue.storage.sqlite.archive_tiers.bootstrap import ARCHIVE_TIER_SPECS -from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier - - -def write_valid_rebuild_receipt( - archive_root: Path, - receipt_path: Path, - *, - generated_at: datetime | None = None, -) -> Path: - """Write a minimally complete, identity-bound PASS for a test archive.""" - - root = archive_root.absolute() - location = ArchiveLocation.resolve(root) - identity = ArchiveIdentity.resolve_location(location) - with sqlite3.connect(root / "source.db") as source: - origins = {str(row[0]) for row in source.execute("SELECT DISTINCT origin FROM raw_sessions ORDER BY origin")} - ground_truth_origins: dict[str, object] = {} - for origin in sorted(origins): - declared = gate.GROUND_TRUTH_INPUTS.get(origin, {"exempt": False}) - if bool(declared.get("exempt")): - ground_truth_origins[origin] = { - "exempt": True, - "reason": declared.get("reason"), - "raw_external_mapping": gate._raw_external_mapping( - source, origin=origin, inventory=(), exempt=True - ), - } - continue - external_root = root.parent / f"{root.name}-{origin}-ground-truth" - external_root.mkdir(parents=True, exist_ok=True) - raw_rows = source.execute( - "SELECT raw_id, blob_hash FROM raw_sessions WHERE origin = ? ORDER BY raw_id", (origin,) - ).fetchall() - for raw_id, blob_hash in raw_rows: - (external_root / f"{raw_id}.bin").write_bytes(BlobStore(root / "blob").read_all(bytes(blob_hash).hex())) - inventory = gate._external_inventory((external_root,)) - inventory_records = [ - { - "root_index": item.root_index, - "relative_path": item.relative_path, - "hash": item.content_hash, - "size": item.size, - } - for item in inventory - ] - mapping = gate._raw_external_mapping(source, origin=origin, inventory=inventory, exempt=False) - ground_truth_origins[origin] = { - "exempt": False, - "declared_roots": [str(external_root)], - "external_inventory": inventory_records, - "inventory_change_detector": gate._external_inventory_change_detector((external_root,)), - "raw_external_mapping": mapping, - "passed": all(item["disposition"] == "matched-external" for item in mapping), - } - - ground_truth = { - "passed": True, - "origins": ground_truth_origins, - } - digest = gate._canonical_external_ground_truth_digest(ground_truth_origins) - ground_truth["external_ground_truth_digest"] = digest - query_results = { - gate_id: {"gate": gate_id, "passed": True, "count": 0} - for gate_id in (*gate._HARD_GATE_SQL, "zero-unexplained-byte-duplicates") - } - source_entry = { - "expected_user_version": ARCHIVE_TIER_SPECS[ArchiveTier.SOURCE].version, - "actual_user_version": ARCHIVE_TIER_SPECS[ArchiveTier.SOURCE].version, - "matches_expected": True, - } - payload: dict[str, object] = { - "schema": gate.RECEIPT_SCHEMA, - "gate_version": gate.GATE_VERSION, - "generated_at": (generated_at or datetime.now(UTC)).astimezone(UTC).isoformat(), - "verdict": "PASS", - "archive_root": str(root), - "archive_identity": gate._archive_receipt_identity(location), - "source_identity": { - "durable_id": identity.durable_id, - "source_tier": identity.tier("source").as_dict(), - }, - "source_snapshot": gate.rebuild_source_revision_snapshot(root), - "external_ground_truth_digest": digest, - "source_schema_identity": source_entry, - "query_results": query_results, - "ground_truth_inputs": ground_truth, - "corpus_fidelity": {"passed": True}, - "full_blob_hash_verification": {"passed": True}, - } - receipt_path.parent.mkdir(parents=True, exist_ok=True) - receipt_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return receipt_path - - -def write_current_rebuild_receipt(archive_root: Path, receipt_path: Path) -> Path: - """Settle fixture source authority, then bind a receipt to that state.""" - - census_historical_revision_evidence(archive_root) - return write_valid_rebuild_receipt(archive_root, receipt_path) - - -__all__ = ["write_current_rebuild_receipt", "write_valid_rebuild_receipt"] diff --git a/tests/unit/cli/commands/test_status.py b/tests/unit/cli/commands/test_status.py index 9f81c33d68..eb39ca9056 100644 --- a/tests/unit/cli/commands/test_status.py +++ b/tests/unit/cli/commands/test_status.py @@ -25,12 +25,8 @@ _view_exists, ) -# polylogue-ogn1: these three moved from polylogue.cli.commands.status to the -# substrate module polylogue.storage.archive_readiness (the layering fix for -# CodeRabbit finding #8 -- polylogue/maintenance/rebuild_index.py, a -# substrate module, was importing the CLI's private _archive_readiness_status -# to gate promotion, inverting this repo's surfaces-may-not-import-substrate -# rule). status.py now delegates to the same shared implementation instead of +# polylogue-ogn1: archive-readiness lives in the substrate so substrate callers +# reach it without importing a surface. status.py delegates here rather than # owning a second copy. from polylogue.storage.archive_readiness import ( _action_readiness_counts, diff --git a/tests/unit/cli/test_maintenance_registration.py b/tests/unit/cli/test_maintenance_registration.py index 7c8cdd50e2..3ec6a43232 100644 --- a/tests/unit/cli/test_maintenance_registration.py +++ b/tests/unit/cli/test_maintenance_registration.py @@ -19,10 +19,7 @@ blob_reference_replace_from_source_command, blob_reference_replace_from_source_preview_command, ) -from polylogue.cli.commands.maintenance._blob_reference_closure import blob_reference_closure_command from polylogue.cli.commands.maintenance._operation_recovery import operation_recovery_command -from polylogue.cli.commands.maintenance._plan import plan_command -from polylogue.cli.commands.maintenance._status import status_command from polylogue.cli.shared.types import AppEnv from polylogue.config import Config from polylogue.services import RuntimeServices @@ -48,11 +45,6 @@ def test_maintenance_group_is_click_group() -> None: assert isinstance(_registered_maintenance_command(), click.Group) -def test_maintenance_plan_is_click_command() -> None: - """plan is a Click Command on the maintenance group.""" - assert isinstance(plan_command, click.Command) - - def test_operation_recovery_is_click_command() -> None: assert isinstance(operation_recovery_command, click.Command) @@ -134,18 +126,6 @@ def test_maintenance_appears_in_ops_help() -> None: assert "maintenance" in result.output -def test_maintenance_group_has_plan_and_run() -> None: - """maintenance group lists plan, run, and run-preview as subcommands.""" - maintenance_group = _registered_maintenance_command() - ctx = click.Context(maintenance_group) - cmds = maintenance_group.list_commands(ctx) # type: ignore[attr-defined] - assert "plan" in cmds - assert "run" in cmds - assert "run-preview" in cmds - assert "operation-recovery" in cmds - assert "blob-conservation" in cmds - - def test_ops_import_keeps_blob_conservation_unloaded() -> None: """Unrelated ops commands do not pay for the census implementation. @@ -206,46 +186,6 @@ def test_blob_conservation_json_returns_failure_for_a_failed_census(monkeypatch: assert json.loads(result.output)["ok"] is False -def test_maintenance_plan_help_output() -> None: - """polylogue ops maintenance plan --help shows plan help.""" - runner = CliRunner() - result = runner.invoke(root_cli, ["ops", "maintenance", "plan", "--help"]) - assert result.exit_code == 0 - assert "Dry-run" in result.output or "summary" in result.output.lower() - - -def test_maintenance_run_help_output() -> None: - """polylogue ops maintenance run --help shows run help. - - ``run`` is the lean apply-only command post-split (polylogue-oou3c): it - no longer carries a ``--dry-run`` flag -- ``run-preview`` is the - dedicated read-only twin instead. - """ - runner = CliRunner() - result = runner.invoke(root_cli, ["ops", "maintenance", "run", "--help"]) - assert result.exit_code == 0 - assert "--dry-run" not in result.output - assert "--operation-id" in result.output - - -def test_maintenance_run_preview_help_output() -> None: - """polylogue ops maintenance run-preview --help shows the preview help.""" - runner = CliRunner() - result = runner.invoke(root_cli, ["ops", "maintenance", "run-preview", "--help"]) - assert result.exit_code == 0 - assert "Read-only" in result.output or "read-only" in result.output.lower() - assert "--operation-id" in result.output - - -def test_maintenance_status_is_click_command() -> None: - """status is a Click Command on the maintenance group (#1197).""" - assert isinstance(status_command, click.Command) - - -def test_blob_reference_closure_is_click_command() -> None: - assert isinstance(blob_reference_closure_command, click.Command) - - def test_blob_integrity_preview_and_apply_commands_are_distinct_click_routes() -> None: """The real Click registry exposes diagnostic and write commands separately.""" @@ -269,66 +209,3 @@ def test_blob_integrity_preview_and_apply_commands_are_distinct_click_routes() - assert "blob-reference-replace-from-source" in commands assert "blob-reference-prune-orphans-preview" in commands assert "blob-reference-prune-orphans" in commands - - -def test_maintenance_group_has_status() -> None: - """maintenance group lists status as a subcommand (#1197).""" - maintenance_group = _registered_maintenance_command() - ctx = click.Context(maintenance_group) - cmds = maintenance_group.list_commands(ctx) # type: ignore[attr-defined] - assert "status" in cmds - assert "blob-reference-closure" in cmds - - -def test_maintenance_status_help_output() -> None: - """polylogue ops maintenance status --help shows the status help.""" - runner = CliRunner() - result = runner.invoke(root_cli, ["ops", "maintenance", "status", "--help"]) - assert result.exit_code == 0 - assert "--operation-id" in result.output - assert "--all" in result.output - - -def test_plan_reports_a_refused_scope_and_exits_nonzero(tmp_path: Path) -> None: - """A permanently refused plan is not a clean archive. - - Anti-vacuity: removing the ``SystemExit(1)`` and the failure-sample loop - from ``plan_command`` makes this red -- the command would print - "Affected: 0 rows" and exit 0 for a request no target can apply. - """ - archive_root = tmp_path / "archive" - initialize_active_archive_root(archive_root) - with ( - patch("polylogue.cli.commands.maintenance._plan.archive_root", return_value=archive_root), - patch("polylogue.cli.commands.maintenance._plan.render_root", return_value=tmp_path), - ): - result = CliRunner().invoke( - root_cli, - ["ops", "maintenance", "plan", "--target", "empty_sessions", "--origin", "claude-code-session"], - ) - - assert result.exit_code == 1 - assert "UnsupportedScopeDimension" in result.output - - -def test_plan_without_a_session_filter_is_not_a_scoped_request(tmp_path: Path) -> None: - """An omitted ``--session-id`` is no narrowing, so the default plan runs. - - Anti-vacuity: dropping ``_coerce_session_ids``'s empty-tuple - normalization makes this red -- Click's ``()`` would read as a session - scope and ``superseded_raw_snapshots``, which honors no dimension, would - refuse with exit 1. - """ - archive_root = tmp_path / "archive" - initialize_active_archive_root(archive_root) - with ( - patch("polylogue.cli.commands.maintenance._plan.archive_root", return_value=archive_root), - patch("polylogue.cli.commands.maintenance._plan.render_root", return_value=tmp_path), - ): - result = CliRunner().invoke( - root_cli, - ["ops", "maintenance", "plan", "--target", "superseded_raw_snapshots"], - ) - - assert result.exit_code == 0 - assert "UnsupportedScopeDimension" not in result.output diff --git a/tests/unit/cli/test_status.py b/tests/unit/cli/test_status.py index 96b2804f27..8d58559b6f 100644 --- a/tests/unit/cli/test_status.py +++ b/tests/unit/cli/test_status.py @@ -34,8 +34,6 @@ from polylogue.core.enums import ArtifactSupportStatus from polylogue.daemon.convergence_debt_status import ConvergenceDebtSummary from polylogue.daemon.events import emit_daemon_event -from polylogue.maintenance.failure_routing import route_failure_sample -from polylogue.maintenance.planner import FailureSample from polylogue.storage.sqlite.archive_tiers import ARCHIVE_VERSION_BY_TIER from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_database from polylogue.storage.sqlite.archive_tiers.source_write import ArchiveSourceArtifact, upsert_raw_artifact @@ -738,7 +736,7 @@ def test_direct_status_json_reports_active_archive_root_for_sibling_index(self, assert payload["archive_tiers"]["user"]["exists"] is False assert payload["raw_records"] == 1 - def test_direct_status_json_reads_maintenance_failure_from_configured_root_with_external_index( + def test_direct_status_json_resolves_an_external_index_from_the_configured_root( self, tmp_path: Path, ) -> None: @@ -759,13 +757,6 @@ def test_direct_status_json_reads_maintenance_failure_from_configured_root_with_ (configured_root / "source.db").symlink_to(farm / "source.db") (configured_root / "index.db").symlink_to(index_db) (configured_root / ".index-active-pointer").write_text(str(index_db), encoding="utf-8") - route_failure_sample( - FailureSample(kind="RuntimeError", locator="target:session_insights", message="root maintenance failure"), - operation_id="root-maintenance", - archive_root=configured_root, - target="session_insights", - ) - with ( patch("polylogue.paths.db_path", return_value=configured_root / "index.db"), patch("polylogue.paths.archive_root", return_value=configured_root), @@ -774,7 +765,6 @@ def test_direct_status_json_reads_maintenance_failure_from_configured_root_with_ payload = json.loads(_combined_calls(env)) assert payload["active_db_path"] == str(index_db) - assert payload["raw_failures"]["maintenance"] == 1 def test_direct_status_reports_archive_surface_blockers(self, tmp_path: Path) -> None: env = _make_app_env() diff --git a/tests/unit/cost/test_contract_suite.py b/tests/unit/cost/test_contract_suite.py index 90720d334a..d6cc38a0b0 100644 --- a/tests/unit/cost/test_contract_suite.py +++ b/tests/unit/cost/test_contract_suite.py @@ -41,7 +41,6 @@ CostEstimatePayload, estimate_session_cost, ) -from polylogue.core.enums import OperationStatus from polylogue.cost.aggregation import session_costs_to_daily_usd from polylogue.cost.outlook import ( CycleOutlook, @@ -60,16 +59,6 @@ cycle_for, plan_by_name, ) -from polylogue.maintenance.cost_backfill import ( - SESSION_PROFILES_REBUILD_TARGET, - SINGLE_BASIS_COST_PROVENANCE_MARKERS, - SINGLE_BASIS_COST_SOURCE, - SingleBasisCostRow, - find_single_basis_cost_rows, - plan_cost_backfill, -) -from polylogue.maintenance.invalidation import InvalidationReason -from polylogue.maintenance.planner import BackfillKind from tests.infra.builders import make_conv, make_msg # --------------------------------------------------------------------------- @@ -569,73 +558,3 @@ async def test_mcp_get_cost_outlook_unknown_plan_reports_typed_error() -> None: payload = _json.loads(raw) assert payload.get("code") == "invalid_argument", payload - - -# --------------------------------------------------------------------------- -# Cost backfill contracts (#1140) -# --------------------------------------------------------------------------- - - -def _single_basis_reader(rows: tuple[SingleBasisCostRow, ...]) -> object: - def _reader( - *, - provenance_markers: frozenset[str] = SINGLE_BASIS_COST_PROVENANCE_MARKERS, - min_total_usd: float = 0.0, - ) -> tuple[SingleBasisCostRow, ...]: - return rows - - return _reader - - -def test_find_single_basis_cost_rows_filters_by_provenance_and_amount() -> None: - """Only untyped positive-cost rows need the single-basis backfill.""" - candidates = ( - SingleBasisCostRow("conv-stale", "claude-code", total_cost_usd=0.42, cost_provenance="unknown"), - # Excluded: already typed provenance. - SingleBasisCostRow("conv-typed", "claude-code", total_cost_usd=0.42, cost_provenance="provider_reported"), - # Excluded: zero cost — no basis to backfill. - SingleBasisCostRow("conv-zero", "chatgpt", total_cost_usd=0.0, cost_provenance="unknown"), - ) - stale_rows = find_single_basis_cost_rows(_single_basis_reader(candidates)) - assert len(stale_rows) == 1 - assert stale_rows[0].session_id == "conv-stale" - - -def test_plan_cost_backfill_emits_typed_backfill() -> None: - """The backfill returns a typed ``BackfillOperation`` with the source tag. - - The planner-driven shape pins: - - ``kind == DERIVED_REBUILD`` - - target == ``session_profiles`` - - reason == ``STALE_MATERIALIZER_VERSION`` - - scope filter carries ``cost_basis = single-basis-cost`` and the - session-id list — both load-bearing for the executor. - """ - rows = ( - SingleBasisCostRow("conv-a", "claude-code", total_cost_usd=1.0, cost_provenance="unknown"), - SingleBasisCostRow("conv-b", "chatgpt", total_cost_usd=2.5, cost_provenance="unknown"), - ) - op = plan_cost_backfill(rows) - assert op.kind is BackfillKind.DERIVED_REBUILD - assert op.status is OperationStatus.PENDING - assert op.targets == (SESSION_PROFILES_REBUILD_TARGET,) - assert op.affected_rows == 2 - assert op.reason is InvalidationReason.STALE_MATERIALIZER_VERSION - assert op.scope is not None - # The cost-backfill plan now uses the typed MaintenanceScopeFilter - # and surfaces the stale session set via ``session_ids``. - # ``cost_basis`` / ``dry_run`` are no longer scope dimensions — they - # are encoded in the per-result rows and in the operation status. - assert op.scope.filter.session_ids == ("conv-a", "conv-b") - # Each result row exposes the source tag so downstream surfaces can render it. - for result in op.results: - assert result["source"] == SINGLE_BASIS_COST_SOURCE - - -def test_plan_cost_backfill_empty_input_produces_zero_affected() -> None: - """An empty stale set still produces a valid pending op with zero work.""" - op = plan_cost_backfill(()) - assert op.affected_rows == 0 - assert op.estimated_time_s == 0.0 - assert op.status is OperationStatus.PENDING - assert op.results == [] diff --git a/tests/unit/maintenance/test_operation_ids.py b/tests/unit/maintenance/test_operation_ids.py deleted file mode 100644 index 72ac5a57f1..0000000000 --- a/tests/unit/maintenance/test_operation_ids.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -from http import HTTPStatus -from pathlib import Path - -import pytest - -from polylogue.config import Config -from polylogue.daemon.maintenance_registry_http import handle_status -from polylogue.maintenance.operation_ids import validate_operation_id -from polylogue.maintenance.registry import MaintenanceOperationRegistry - - -@pytest.mark.parametrize("value", ["", "/tmp/escape", "../escape", "..", ".", "a/b", r"a\\b", 0, False, [], Path("op")]) -def test_validate_operation_id_rejects_untrusted_values(value: object) -> None: - with pytest.raises(ValueError): - validate_operation_id(value) - - -def test_validate_operation_id_preserves_opaque_ids() -> None: - assert validate_operation_id("operation:abc-123") == "operation:abc-123" - - -def test_validate_operation_id_rejects_none_generation_sentinel() -> None: - with pytest.raises(ValueError): - validate_operation_id(None) - - -def test_registry_rejects_hostile_id_before_path_lookup(tmp_path: Path) -> None: - config = Config(archive_root=tmp_path, render_root=tmp_path / "render", sources=[]) - registry = MaintenanceOperationRegistry(config=config) - with pytest.raises(ValueError): - registry.get_operation("../escape") - - -def test_daemon_status_rejects_url_decoded_hostile_id() -> None: - class Handler: - def __init__(self) -> None: - self.error: tuple[object, ...] | None = None - - def _send_error(self, *args: object) -> None: - self.error = args - - handler = Handler() - handle_status(handler, "../escape") - assert handler.error is not None - assert handler.error[0] is HTTPStatus.BAD_REQUEST - assert handler.error[1] == "invalid_operation_id" diff --git a/tests/unit/scenarios/test_codex_804_live_proof.py b/tests/unit/scenarios/test_codex_804_live_proof.py index c837503fe5..6207cd6c61 100644 --- a/tests/unit/scenarios/test_codex_804_live_proof.py +++ b/tests/unit/scenarios/test_codex_804_live_proof.py @@ -3,19 +3,18 @@ The witness is structural rather than private: 804 full-snapshot acquisitions share one source path, the terminal snapshot is exactly 90,822,451 bytes, and the content contains only synthetic identifiers and padding. The test still -uses the production acquisition, parser, convergence, resumable rebuild, and -generation-promotion seams. It is deliberately a proof harness, not a second -archive writer. +uses the production acquisition, parser, and convergence seams. It is +deliberately a proof harness, not a second archive writer. The live archive and the real 2026-07-31 witness are unavailable to this lane. The remaining confidence gap is therefore the live-operation receipt tracked by ``polylogue-live-operation-receipts`` and the terminal production proof ``polylogue-reindex-final-proof``. -Where the three audited defects are enforced. A Codex post-merge audit of -PR #3855 named three ways this harness could accept a bad run. Each now has a -named postcondition here plus a red-mutation test at the bottom of the module, -so a future reader does not have to re-derive them from the audit comments: +Where the audited defects are enforced. A Codex post-merge audit of PR #3855 +named ways this harness could accept a bad run. Each has a named postcondition +here plus a red-mutation test at the bottom of the module, so a future reader +does not have to re-derive them from the audit comments: ``comment 3728404649`` -- revisions regenerating timestamps for provider IDs that already exist, letting a growth chain pass through conflict fallback @@ -23,11 +22,6 @@ (per revision) and, in total form, by ``_WirePrefixPreservationWitness``, which requires revision ``N``'s leading bytes to hash to revision ``N-1``'s whole-file digest for all 804 revisions. -``comment 3728830133`` -- a kill that waits until the transaction is already - paused, missing the pre-checkpoint crash window. Enforced by the - ``precheckpoint_script`` subprocess, which hard-exits *inside* - ``checkpoint_transaction`` before the original call can publish durable - paused state, and by ``_assert_precheckpoint_state``. ``comment 3728830142`` -- a final postcondition accepting zero memberships or quarantined authority, admitting unresolved historical revisions. Enforced by ``_assert_exact_authority_census``, which requires every raw to be @@ -45,7 +39,6 @@ import resource import sqlite3 import subprocess -import sys import time from dataclasses import replace from datetime import datetime @@ -72,11 +65,7 @@ from polylogue.schemas.operator.receipt import package_hashes_for_registry from polylogue.schemas.registry import SCHEMA_DIR, SchemaRegistry from polylogue.sources.revision_backfill import RAW_AUTHORITY_PARSER_FINGERPRINT -from polylogue.storage.archive_readiness import raw_materialization_readiness_snapshot -from polylogue.storage.index_generation import IndexGenerationStore, rebuild_source_evidence_snapshot from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_active_archive_root -from tests.infra.archive_canonical_snapshot import archive_snapshot -from tests.infra.rebuild_receipt import write_valid_rebuild_receipt from tests.infra.whale_fixtures import ( WHALE_FIXTURE_DIMENSIONS, CodexRevisionChainFixture, @@ -168,22 +157,6 @@ def observe(self, revision: int, path: Path) -> None: self.previous_sha256 = whole_sha256 -def _assert_precheckpoint_state( - *, - status: str, - processed_raw_count: int, - last_raw_id: str | None, - last_blob_hash_hex: str | None, - receipt_names: tuple[str, ...], -) -> None: - """Require a kill before the durable paused checkpoint to stay running.""" - assert status == "running" - assert processed_raw_count == 0 - assert last_raw_id is None - assert last_blob_hash_hex is None - assert receipt_names == () - - def _assert_exact_authority_census( *, raw_ids: set[str], @@ -325,25 +298,17 @@ def _source_facts( return int(row[0]), int(row[1]), int(row[2]), int(row[3]), authorities, raw_rows -def _readiness_count(readiness: dict[str, object], key: str) -> int: - value = readiness[key] - if isinstance(value, bool) or not isinstance(value, (int, str)): - raise AssertionError(f"readiness field {key!r} is not count-shaped: {value!r}") - return int(value) - - @pytest.mark.timeout(900) -@pytest.mark.uses_real_clock("waits for a real subprocess replay checkpoint and kill/resume boundary") +@pytest.mark.uses_real_clock("measures wall-clock cost of an incident-scale convergence pass") def test_sanitized_codex_804_revision_recovery_proof(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Exercise acquisition through candidate promotion at incident scale. + """Exercise acquisition through raw-authority convergence at incident scale. Anti-vacuity: deleting the production ``AcquisitionService`` call from ``acquire_codex_revision_chain`` leaves the source row count at zero; deleting the production ``LiveBatchProcessor.plan_append`` call leaves no verified 804..819 append-plan sequence; - deleting parser/convergence leaves the terminal index session absent; - deleting the resumable candidate path leaves no paused transaction or - inactive generation for the recovery assertions below. + deleting parser/convergence leaves the terminal index session absent and + the byte-authority census below unresolved. """ root = tmp_path / "codex-804-proof" @@ -483,13 +448,10 @@ def observe_revision(revision: int, path: Path) -> None: ) ) - # Source remediation is a phase-2 input to candidate construction. Run - # the production remediation route in an isolated archive, then carry its - # finalized durable source tier into this fresh-index candidate fixture. - # This keeps the rebuild receipt frozen after remediation, so a candidate - # replay cannot hide a source mutation behind its own provenance gate. - # The replay phase intentionally begins before source remediation and the - # crash boundary so its receipt covers the complete recovery envelope. + # Convergence runs in an isolated archive holding a copy of the durable + # source tier and blob tree, so the index it derives is attributable to + # this pass alone; the resolved source tier is then carried back into the + # proof root. The replay phase spans the whole convergence envelope. replay_before = _resource_sample(tmp_path) replay_started = time.perf_counter() source_ready_root = tmp_path / "codex-804-source-ready" @@ -529,321 +491,19 @@ def observe_revision(revision: int, path: Path) -> None: ) is None ) - schema_inference_receipt_path = write_valid_rebuild_receipt(root, tmp_path / "schema-inference-gate-receipt.json") - store = IndexGenerationStore.for_archive_root(root) - active_before = store.active_pointer.resolve(strict=True) - baseline = archive_snapshot(root) - - transaction = store.create_transaction( - source_snapshot=rebuild_source_evidence_snapshot(root), - # The proof must spend one bounded restart pass on the complete - # synthetic corpus. A small production budget would make every retry - # re-enter frozen replay for the same 804-revision cohort, turning a - # restart proof into an unbounded terminal-stage stress loop. - pass_byte_budget=whale_component_bytes + 1, - ) - operation_id = transaction.operation_id - assert transaction.status == "running" - transaction_path = store.transactions_root / f"{operation_id}.json" - assert transaction_path.is_file() - precheckpoint_script = """ -import os -import sys -from pathlib import Path - -from polylogue.storage.index_generation import IndexGenerationStore - -original_checkpoint = IndexGenerationStore.checkpoint_transaction - -def terminate_before_page_checkpoint(self, transaction, **kwargs): - if kwargs.get("status") == "paused" and kwargs.get("processed_raw_count", 0) > 0: - os._exit(97) - return original_checkpoint(self, transaction, **kwargs) - -IndexGenerationStore.checkpoint_transaction = terminate_before_page_checkpoint -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - -root = Path(sys.argv[1]) -operation_id = sys.argv[2] -receipt = Path(sys.argv[3]) -rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=operation_id, - promote=False, - schema_inference_receipt_path=receipt, - raw_batch_size=8, - ) -) -raise SystemExit("checkpoint seam was not reached") -""" - precheckpoint_process = subprocess.run( - [sys.executable, "-c", precheckpoint_script, str(root), operation_id, str(schema_inference_receipt_path)], - cwd=Path.cwd(), - capture_output=True, - text=True, - check=False, - timeout=120, - ) - assert precheckpoint_process.returncode == 97, ( - f"pre-checkpoint boundary did not terminate at the checkpoint seam: " - f"returncode={precheckpoint_process.returncode}; " - f"stdout={precheckpoint_process.stdout}; stderr={precheckpoint_process.stderr}" - ) - persisted_before_page = store.load_transaction(operation_id) - # Mutation that advances the cursor before checkpoint_transaction returns - # fails these pre-checkpoint invariants and the receipt census below. - receipt_directory = store.transactions_root / f"{operation_id}.receipts" - _assert_precheckpoint_state( - status=persisted_before_page.status, - processed_raw_count=persisted_before_page.processed_raw_count, - last_raw_id=persisted_before_page.last_raw_id, - last_blob_hash_hex=persisted_before_page.last_blob_hash_hex, - receipt_names=tuple(path.name for path in receipt_directory.glob("pass-*.json")), - ) - precheckpoint_generation = store.load(persisted_before_page.generation_id) - with sqlite3.connect(precheckpoint_generation.index_path) as conn: - assert int(conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]) > 0 - committed_page = store.next_raw_page(persisted_before_page, limit=8) - committed_page_raw_ids = tuple(row[0] for row in committed_page.rows) - assert len(committed_page_raw_ids) == 8 - replay_script = """ -import sys -from pathlib import Path - -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - -root = Path(sys.argv[1]) -operation_id = sys.argv[2] -receipt = Path(sys.argv[3]) -result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=operation_id, - promote=False, - schema_inference_receipt_path=receipt, - raw_batch_size=8, - ) -) -print(result.status) -""" - replay_process = subprocess.Popen( - [sys.executable, "-c", replay_script, str(root), operation_id, str(schema_inference_receipt_path)], - cwd=Path.cwd(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - replay_deadline = time.monotonic() + 120 - while time.monotonic() < replay_deadline: - persisted = store.load_transaction(operation_id) - if persisted.processed_raw_count > 0: - replay_process.kill() - break - returncode = replay_process.poll() - if returncode is not None: - stdout = replay_process.stdout.read() if replay_process.stdout is not None else "" - stderr = replay_process.stderr.read() if replay_process.stderr is not None else "" - raise AssertionError( - f"replay exited before durable progress: {returncode}; stdout={stdout}; stderr={stderr}" - ) - time.sleep(0.1) - else: - replay_process.kill() - raise AssertionError("replay did not checkpoint durable progress before the interruption deadline") - replay_returncode = replay_process.wait(timeout=30) - assert replay_returncode == -9 - persisted = store.load_transaction(operation_id) - assert persisted.status == "paused" - assert persisted.processed_raw_count == len(committed_page_raw_ids) - interrupted_generation = store.load(persisted.generation_id) - with sqlite3.connect(interrupted_generation.index_path) as conn: - assert int(conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]) > 0 - # polylogue-b5l.1 (#4285): the generation-local sealed membership is - # the resume authority, so what the restart owes is the sealed order - # minus every raw already committed -- which includes the page the - # pre-checkpoint crash committed before exiting without a checkpoint. - committed_before_restart = frozenset( - str(row[0]) - for row in conn.execute("SELECT raw_id FROM candidate_source_membership WHERE status = 'committed'") - ) - assert set(committed_page_raw_ids) <= committed_before_restart - assert store.active_pointer.resolve(strict=True) == active_before phases.append( _phase( "replay", replay_before, _resource_sample(tmp_path), replay_started, - completed=0, - total=expected_raw_count, - rss_available=False, - ) - ) - - resume_before = _resource_sample(root) - resume_started = time.perf_counter() - resume_script = f""" -import json -import sys -from pathlib import Path - -import polylogue.maintenance.replay as replay_module - -trace = Path(sys.argv[4]) -real_replay = replay_module.rebuild_index_from_source - -async def recording_replay(*args, **kwargs): - with trace.open("a", encoding="utf-8") as stream: - stream.write(json.dumps(list(kwargs["raw_ids"]), sort_keys=True) + "\\n") - return await real_replay(*args, **kwargs) - -replay_module.rebuild_index_from_source = recording_replay -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - -root = Path(sys.argv[1]) -operation_id = sys.argv[2] -receipt = Path(sys.argv[3]) -result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=operation_id, - promote=False, - schema_inference_receipt_path=receipt, - raw_batch_size={expected_raw_count}, - ) -) -if result.status != "replayed" or not result.materialized: - raise SystemExit(f"bounded restart did not reach replayed: {{result.status!r}}") -print(result.generation["generation_id"]) -""" - replay_trace_path = tmp_path / "rebuild-replay-trace.jsonl" - resumed_process = subprocess.run( - [ - sys.executable, - "-c", - resume_script, - str(root), - operation_id, - str(schema_inference_receipt_path), - str(replay_trace_path), - ], - cwd=Path.cwd(), - check=False, - capture_output=True, - text=True, - ) - assert resumed_process.returncode == 0, ( - f"restart subprocess failed: returncode={resumed_process.returncode}; " - f"stdout={resumed_process.stdout}; stderr={resumed_process.stderr}" - ) - generation_id = resumed_process.stdout.strip().splitlines()[-1] - assert generation_id.startswith("gen-") - persisted_after_restart = store.load_transaction(operation_id) - assert persisted_after_restart.status == "ready" - candidate = store.load(generation_id) - assert candidate.state == "inactive" - assert Path(candidate.index_path).is_file() - assert store.active_pointer.resolve(strict=True) == active_before - resumed_raw_pages = tuple( - tuple(json.loads(line)) for line in replay_trace_path.read_text(encoding="utf-8").splitlines() - ) - assert resumed_raw_pages, "restart observed no production replay selections for the suffix" - resumed_raw_sequence = tuple(raw_id for page in resumed_raw_pages for raw_id in page) - with sqlite3.connect(root / "source.db") as conn: - all_raw_sequence = tuple( - str(row[0]) for row in conn.execute("SELECT raw_id FROM raw_sessions ORDER BY blob_hash, raw_id") - ) - with sqlite3.connect(candidate.index_path) as conn: - sealed_raw_sequence = tuple( - str(row[0]) - for row in conn.execute("SELECT raw_id FROM candidate_source_membership ORDER BY blob_hash, raw_id") - ) - assert all(isinstance(raw_id, str) for page in resumed_raw_pages for raw_id in page) - assert len(resumed_raw_sequence) == len(set(resumed_raw_sequence)), "restart replayed a raw more than once" - assert len(all_raw_sequence) == len(set(all_raw_sequence)) - # No raw of this fixture carries a resolved-superseded membership row - # (``membership_rows == ()`` below), so the seal is the whole source head: - # a seal that silently dropped an eligible raw fails here, not merely in - # the suffix comparison. - assert sealed_raw_sequence == all_raw_sequence - assert set(committed_page_raw_ids).isdisjoint(set(resumed_raw_sequence)) - assert resumed_raw_sequence == tuple( - raw_id for raw_id in sealed_raw_sequence if raw_id not in committed_before_restart - ) - assert set(resumed_raw_sequence) | committed_before_restart == set(sealed_raw_sequence) - - idempotence_script = """ -import json -import sys -from pathlib import Path - -import polylogue.maintenance.replay as replay_module - -trace = Path(sys.argv[4]) -real_replay = replay_module.rebuild_index_from_source - -async def recording_replay(*args, **kwargs): - with trace.open("a", encoding="utf-8") as stream: - stream.write(json.dumps(list(kwargs["raw_ids"]), sort_keys=True) + "\\n") - return await real_replay(*args, **kwargs) - -replay_module.rebuild_index_from_source = recording_replay -from polylogue.maintenance.rebuild_index import RebuildIndexRequest, rebuild_index_from_source_sync - -root = Path(sys.argv[1]) -operation_id = sys.argv[2] -receipt = Path(sys.argv[3]) -result = rebuild_index_from_source_sync( - RebuildIndexRequest( - archive_root=root, - operation_id=operation_id, - promote=False, - schema_inference_receipt_path=receipt, - raw_batch_size=804, - ) -) -print(json.dumps({"status": result.status, "generation_id": result.generation["generation_id"]})) -""" - idempotence_trace_path = tmp_path / "rebuild-idempotence-trace.jsonl" - idempotent_process = subprocess.run( - [ - sys.executable, - "-c", - idempotence_script, - str(root), - operation_id, - str(schema_inference_receipt_path), - str(idempotence_trace_path), - ], - cwd=Path.cwd(), - check=False, - capture_output=True, - text=True, - timeout=120, - ) - assert idempotent_process.returncode == 0, ( - f"idempotent restart failed: returncode={idempotent_process.returncode}; " - f"stdout={idempotent_process.stdout}; stderr={idempotent_process.stderr}" - ) - idempotent_result = json.loads(idempotent_process.stdout.strip().splitlines()[-1]) - assert idempotent_result == {"status": "replayed", "generation_id": generation_id} - idempotent_pages = tuple( - tuple(json.loads(line)) for line in idempotence_trace_path.read_text(encoding="utf-8").splitlines() - ) - assert all(not page for page in idempotent_pages), "idempotent restart replayed a raw revision" - phases.append( - _phase( - "postflight", - resume_before, - _resource_sample(root), - resume_started, completed=expected_raw_count, total=expected_raw_count, - rss_available=False, ) ) + postflight_before = _resource_sample(root) + postflight_started = time.perf_counter() raw_count, max_blob_size, source_path_count, parse_error_count, authorities, raw_rows = _source_facts(root) assert raw_count == expected_raw_count assert max_blob_size == TERMINAL_WIRE_BYTES @@ -911,24 +571,7 @@ async def recording_replay(*args, **kwargs): assert len(source_keys) == 1 terminal_logical_source_key = membership_keys[0] if membership_keys else source_keys[0] - store.promote(candidate) - final_snapshot = archive_snapshot(root, search_queries=("sanitized",)) - baseline_sessions = next(item for item in baseline.canonical_rows if item.relation == "sessions") - assert not baseline_sessions.rows - sessions_relation = next(item for item in final_snapshot.canonical_rows if item.relation == "sessions") - assert sessions_relation.rows - public = dict(final_snapshot.public_projections) - indexed_session_id = f"codex-session:{SESSION_NATIVE_ID}" - assert public[f"summary:{indexed_session_id}"] - assert public[f"tree:{indexed_session_id}"] - assert public["search:sanitized"] - readiness = raw_materialization_readiness_snapshot(root) - assert readiness["available"] is True - assert _readiness_count(readiness, "raw_artifact_count") == expected_raw_count - materialized_raw_count = _readiness_count(readiness, "materialized_raw_artifact_count") - assert 0 < materialized_raw_count <= REVISION_COUNT - assert _readiness_count(readiness, "join_gap_count") == expected_raw_count - materialized_raw_count - with sqlite3.connect(root / "index.db") as conn: + with sqlite3.connect(source_ready_root / "index.db") as conn: indexed = conn.execute( "SELECT session_id, raw_id, message_count FROM sessions WHERE native_id = ?", (SESSION_NATIVE_ID,), @@ -1027,7 +670,7 @@ async def recording_replay(*args, **kwargs): int(datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() * 1000) for timestamp in _BASELINE_MESSAGE_TIMESTAMPS ) - with sqlite3.connect(candidate.index_path) as conn: + with sqlite3.connect(source_ready_root / "index.db") as conn: persisted_baseline_timestamps = tuple( int(row[0]) for row in conn.execute( @@ -1037,6 +680,17 @@ async def recording_replay(*args, **kwargs): ) assert persisted_baseline_timestamps == expected_baseline_timestamps + phases.append( + _phase( + "postflight", + postflight_before, + _resource_sample(root), + postflight_started, + completed=expected_raw_count, + total=expected_raw_count, + ) + ) + quiescent = _resource_sample(root) phases.append( WorkloadPhaseObservation( @@ -1072,19 +726,17 @@ async def recording_replay(*args, **kwargs): build_id=f"git:{_git_head()}", runtime_id="production-corpus-runtime", archive_id=f"archive:{hashlib.sha256(str(root).encode()).hexdigest()}", - generation_id=generation_id, + generation_id=None, frame_id=None, phases=tuple(phases), evidence_refs=( "fixture:codex-804-sanitized", f"schema-registry:{profile_id}", - f"candidate-generation:{generation_id}", f"fixture-manifest-sha256:{fixture_manifest_digest}", f"source-raw-count:{raw_count}", f"source-parse-error-count:{parse_error_count}", - f"recovery-unresolved-before-crash:{pre_recovery_unresolved_count}", - f"recovery-memberships-after-restart:{post_recovery_membership_count}", - "restart-boundary:subprocess-persisted-transaction", + f"recovery-unresolved-before-convergence:{pre_recovery_unresolved_count}", + f"recovery-memberships-after-convergence:{post_recovery_membership_count}", "successor:polylogue-live-operation-receipts", "successor:polylogue-reindex-final-proof", ), @@ -1093,8 +745,7 @@ async def recording_replay(*args, **kwargs): "Sanitized structural witness only; no live /realm/state/polylogue access.", "Fixture setup includes 804 on-disk payload revisions, schema hashing, and a canonical fixture manifest.", f"Serialized fixture manifest digest is sha256:{fixture_manifest_digest} and is bound into the receipt input identity.", - "Replay and postflight subprocess RSS is unavailable because statm samples only the pytest parent; storage growth is not reported as write I/O.", - "The crash boundary hard-exits after durable transaction creation with the full-and-append authority cohort unresolved; a fresh process resumes and materializes it into an inactive candidate.", + "Peak RSS and write I/O are unreported; storage growth is sampled as directory tree size.", "Live confidence remains open until the named successor receipts bind the active archive.", ), ) @@ -1117,7 +768,6 @@ async def recording_replay(*args, **kwargs): "terminal_raw_id": selected_raw_id, "indexed_session": indexed[0][0], "message_count": indexed[0][2], - "candidate_generation": generation_id, "resource_phases": [phase.to_payload() for phase in phases], "live_confidence_gap": ["polylogue-live-operation-receipts", "polylogue-reindex-final-proof"], }, @@ -1132,17 +782,6 @@ def test_codex_804_timestamp_red_mutation_is_rejected() -> None: _assert_baseline_timestamps_are_stable(mutated) -def test_codex_804_false_paused_state_red_mutation_is_rejected() -> None: - with pytest.raises(AssertionError): - _assert_precheckpoint_state( - status="paused", - processed_raw_count=8, - last_raw_id="raw-008", - last_blob_hash_hex="deadbeef", - receipt_names=("pass-001.json",), - ) - - def test_codex_804_incomplete_authority_red_mutation_is_rejected() -> None: with pytest.raises(AssertionError): _assert_exact_authority_census( From 84adc4eb135e37e57e2438a26bf436d8d52ca779 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 20:24:57 +0200 Subject: [PATCH 08/11] test: resolve the maintenance Literal and drop the retired failure count get_type_hints resolves the handler's operation Literal; inspect.signature returns the unevaluated string under postponed annotations, so the retired- operation assertion read an empty set. The status payload counts no maintenance failures, so its key leaves the expected projection and the count test goes with the projection it measured. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- tests/unit/cli/test_status.py | 43 --------------------------- tests/unit/mcp/test_tool_discovery.py | 7 +++-- 2 files changed, 4 insertions(+), 46 deletions(-) diff --git a/tests/unit/cli/test_status.py b/tests/unit/cli/test_status.py index 8d58559b6f..bf8cf971db 100644 --- a/tests/unit/cli/test_status.py +++ b/tests/unit/cli/test_status.py @@ -366,48 +366,6 @@ def test_direct_status_reads_archive_file_set_from_archive_tiers(self, tmp_path: assert "Messages: 2" in combined assert "Raw records: 1" in combined - def test_direct_status_counts_maintenance_raw_failures(self, tmp_path: Path) -> None: - env = _make_app_env() - db_anchor = tmp_path / "custom.sqlite" - index_db = tmp_path / "index.db" - source_db = tmp_path / "source.db" - with sqlite3.connect(index_db) as conn: - conn.executescript( - """ - CREATE TABLE sessions (session_id TEXT PRIMARY KEY, message_count INTEGER NOT NULL); - INSERT INTO sessions VALUES ('codex-session:one', 1); - """ - ) - with sqlite3.connect(source_db) as conn: - conn.executescript( - """ - CREATE TABLE raw_sessions (raw_id TEXT PRIMARY KEY); - INSERT INTO raw_sessions VALUES ('raw-1'); - """ - ) - - with ( - patch("polylogue.paths.db_path", return_value=db_anchor), - patch("polylogue.paths.archive_root", return_value=tmp_path), - patch( - "polylogue.cli.commands.status._direct_raw_failure_status", - return_value={ - "raw_parse_failures": 0, - "raw_validation_failures": 0, - "raw_quarantined": 0, - "raw_maintenance_failures": 3, - "raw_deferred_failures": 0, - "raw_terminal_rejections": 0, - "raw_unexplained_failures": 0, - "raw_failure_samples": [], - }, - ), - patch("polylogue.cli.commands.status_diagnostics.diagnose_first_run"), - ): - _show_direct_status(env) - - assert "Raw failures: 3 total" in _combined_calls(env) - def test_direct_status_reports_sqlite_maintenance_state(self, tmp_path: Path) -> None: env = _make_app_env() db_anchor = tmp_path / "custom.sqlite" @@ -642,7 +600,6 @@ def test_direct_status_json_keeps_stopped_daemon_lifecycle_visible(self, tmp_pat "parse": 1, "validation": 0, "quarantined": 1, - "maintenance": 0, "deferred_retryable": 1, "terminal_rejections": 0, "unexplained": 0, diff --git a/tests/unit/mcp/test_tool_discovery.py b/tests/unit/mcp/test_tool_discovery.py index 14e285bccc..2d942ba0bf 100644 --- a/tests/unit/mcp/test_tool_discovery.py +++ b/tests/unit/mcp/test_tool_discovery.py @@ -134,13 +134,14 @@ def test_retired_maintenance_operations_are_not_declared() -> None: to the ``operation`` Literal in ``server_cutover.register_cutover_privileged_tools`` turns this red. """ - import inspect import typing server = cast(MCPServerUnderTest, build_server(capabilities=ALL_CAPABILITIES)) tool = server._tool_manager._tools["maintenance"] - annotation = inspect.signature(tool.fn).parameters["operation"].annotation - declared = set(typing.get_args(annotation)) + # The handler is defined under ``from __future__ import annotations``, so the + # Literal only resolves through get_type_hints. + hints = typing.get_type_hints(tool.fn) + declared = set(typing.get_args(hints["operation"])) assert declared assert not (declared & _RETIRED_MAINTENANCE_OPERATIONS), ( From f09887fbfce1cc9ad8fa9ca428e2b689efab0bef Mon Sep 17 00:00:00 2001 From: Sinity Date: Sun, 6 Sep 2026 00:18:08 +0200 Subject: [PATCH 09/11] test: point the daemon proofs at converge_materialization Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- tests/unit/daemon/test_daemon_cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 8b2c654349..944dde72d3 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -620,7 +620,7 @@ def test_drain_raw_materialization_once_refuses_only_the_broken_paths( """A broken path is left out and recorded as debt; the rest of the backlog converges. Anti-vacuity: raising on any attributed refusal (the pre-split gate) - never reaches ``repair_materialization``; dropping the + never reaches ``converge_materialization``; dropping the ``excluded_source_paths`` passthrough materializes the refused path; and not recording the refusal leaves ``convergence_debt`` empty. Each turns one assertion red. @@ -654,7 +654,7 @@ def fake_repair(config: Config, **kwargs: object) -> object: "polylogue.storage.blob_integrity.restore_direct_blob_reference_debt", lambda *_args, **_kwargs: SimpleNamespace(restored_count=0), ) - monkeypatch.setattr("polylogue.maintenance.raw_authority.repair_materialization", fake_repair) + monkeypatch.setattr("polylogue.maintenance.raw_authority.converge_materialization", fake_repair) monkeypatch.setattr( "polylogue.storage.raw_reconciler.recover_interrupted_raw_authority_frontier", lambda _config: (), @@ -747,10 +747,10 @@ def test_whale_writer_route_refuses_a_seed_on_a_refused_path( ) def fake_repair(*_args: object, **_kwargs: object) -> object: - mutations.append("repair_materialization") + mutations.append("converge_materialization") return SimpleNamespace(success=True, repaired_count=1, detail="unexpected writer call") - monkeypatch.setattr("polylogue.maintenance.raw_authority.repair_materialization", fake_repair) + monkeypatch.setattr("polylogue.maintenance.raw_authority.converge_materialization", fake_repair) monkeypatch.setattr(daemon_cli, "_close_raw_materialization_fts", lambda _path, *, ops_db_path: None) monkeypatch.setattr(daemon_cli, "_emit_raw_materialization_pass", lambda _result: None) From b645eaf2d7fa174f007c8885d375907466bc36cd Mon Sep 17 00:00:00 2001 From: Sinity Date: Sun, 6 Sep 2026 00:20:56 +0200 Subject: [PATCH 10/11] test: resolve a real config behind the daemon config seam Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- tests/unit/daemon/test_daemon_cli.py | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/unit/daemon/test_daemon_cli.py b/tests/unit/daemon/test_daemon_cli.py index 944dde72d3..fbbce3b0a0 100644 --- a/tests/unit/daemon/test_daemon_cli.py +++ b/tests/unit/daemon/test_daemon_cli.py @@ -38,6 +38,20 @@ from tests.infra.frozen_clock import FrozenClock +def _resolved_config(**overrides: object) -> Any: + """Return a ``load_polylogue_config`` stand-in answering every config key. + + Daemon modules bind ``load_polylogue_config`` at import time, so a module + first imported while this seam is patched keeps the stand-in for the rest + of the process. Only a fully resolved config can answer the keys those + later readers ask for. + """ + from polylogue.config import load_polylogue_config + + resolved = load_polylogue_config(cli_overrides=dict(overrides)) + return lambda **_kwargs: resolved + + def test_polylogued_help_lists_watch_command() -> None: result = CliRunner().invoke(main, ["--help"]) @@ -4159,7 +4173,7 @@ def test_maybe_run_raw_materialization_whale_pass_runs_scoped_pass_and_emits_eve monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda: SimpleNamespace( + _resolved_config( raw_authority_whale_payload_bytes=None, daemon_parse_stage_workers=1, daemon_parse_stage_max_inflight_bytes=1_000_000, @@ -4397,7 +4411,7 @@ def test_whale_worker_timeout_fences_before_coordinator_acquisition( monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda: SimpleNamespace( + _resolved_config( raw_authority_whale_payload_bytes=None, daemon_parse_stage_warm_timeout_seconds=0.01, ), @@ -4453,7 +4467,7 @@ def test_whale_completion_accounts_for_census_pending_debt(monkeypatch: pytest.M monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda: SimpleNamespace(raw_authority_whale_payload_bytes=None, daemon_parse_stage_warm_timeout_seconds=1.0), + _resolved_config(raw_authority_whale_payload_bytes=None, daemon_parse_stage_warm_timeout_seconds=1.0), ) monkeypatch.setattr( "polylogue.maintenance.raw_authority.whale_pass_candidate", @@ -4513,9 +4527,8 @@ def test_whale_cancellation_after_admission_records_continuation_then_real_compl monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda **_kwargs: SimpleNamespace( + _resolved_config( archive_root=tmp_path, - render_root=tmp_path / "render", raw_authority_whale_payload_bytes=None, daemon_parse_stage_warm_timeout_seconds=1.0, ), @@ -4644,7 +4657,7 @@ def test_whale_callback_runs_real_coordinator_product_convergence_and_census( monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda: SimpleNamespace( + _resolved_config( raw_authority_whale_payload_bytes=whale_limit, daemon_parse_stage_workers=1, daemon_parse_stage_max_inflight_bytes=whale_limit, @@ -4709,9 +4722,8 @@ def test_maybe_run_raw_materialization_whale_pass_interruption_is_pre_hold( monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda **_kwargs: SimpleNamespace( + _resolved_config( archive_root=tmp_path, - render_root=tmp_path / "render", raw_authority_whale_payload_bytes=None, ), ) @@ -4761,7 +4773,7 @@ def test_maybe_run_raw_materialization_whale_pass_no_candidate_skips_writer( monkeypatch.setattr("polylogue.paths.render_root", lambda: tmp_path / "render") monkeypatch.setattr( "polylogue.config.load_polylogue_config", - lambda: SimpleNamespace( + _resolved_config( raw_authority_whale_payload_bytes=None, daemon_parse_stage_workers=1, daemon_parse_stage_max_inflight_bytes=1_000_000, From 1a33b6b25f00e4b6146a77102fab45cc0421cf02 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sun, 6 Sep 2026 00:44:06 +0200 Subject: [PATCH 11/11] docs: name the surviving materialization entry point Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- docs/design/convergence-simplification-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/design/convergence-simplification-inventory.md b/docs/design/convergence-simplification-inventory.md index ccc6ea5dbd..06dd3a0ff2 100644 --- a/docs/design/convergence-simplification-inventory.md +++ b/docs/design/convergence-simplification-inventory.md @@ -161,7 +161,7 @@ boundary from somewhere else. **What it is:** `_RAW_MATERIALIZATION_DAEMON_BLOB_LIMIT_BYTES = 64 * 1024 * 1024` (`polylogue/daemon/cli.py:89`), threaded as `max_payload_bytes` into every -daemon-driven `repair_materialization` call +daemon-driven `converge_materialization` call (`polylogue/daemon/cli.py:154` and `:850`). It caps how large a raw's blob the daemon's conveyor will parse per pass; a raw above this envelope is deferred (`record_resource_blocked_revision_census`,