From dca8a4676cd9a7dc49d98c1a431b67f658cf80b7 Mon Sep 17 00:00:00 2001 From: Jason Murray Date: Sun, 25 Oct 2026 13:26:25 +0000 Subject: [PATCH] fix(catalog): store unsigned 64-bit stat identities on Windows On Windows, Python reports st_dev (volume serial) and st_ino (file ID) as unsigned 64-bit integers. When either exceeds 2**63 - 1, binding it to the catalog's INTEGER columns raises OverflowError, which the refresh loop records as scan_failed, so every root on that volume reports a root error and nothing is indexed. Fold both values into SQLite's signed INTEGER range before storing them and before comparing against stored rows, so incremental refresh keeps detecting unchanged files. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WW649bqoSmoitkj96NB5WB --- CHANGELOG.md | 6 ++++++ src/session_migrate/catalog.py | 23 +++++++++++++++++------ tests/test_catalog.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19cae84..4d9e447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ here. Native format compatibility is documented separately in ## Unreleased +- Fix `catalog refresh` reporting every root as `scan_failed` on Windows + volumes whose serial number (or file ID) exceeds `2**63 - 1`. Python reports + `st_dev`/`st_ino` there as unsigned 64-bit values, which SQLite rejected with + `OverflowError`; they are now folded into the signed INTEGER range before + being stored or compared. + ## 0.12.0 - 2026-10-19 - Fix atomic writes on Windows in `write_private_atomic`: skip the Unix-only diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 95269ec..b6b328d 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -1175,8 +1175,8 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: unchanged = bool( previous and previous["status"] != "missing" - and int(previous["device"]) == before.device - and int(previous["inode"]) == before.inode + and int(previous["device"]) == _sqlite_int(before.device) + and int(previous["inode"]) == _sqlite_int(before.inode) and int(previous["bytes"]) == before.size and int(previous["modified_ns"]) == before.modified_ns and previous["source_fingerprint"] == getattr(before, "fingerprint", None) @@ -1544,8 +1544,8 @@ def _upsert_session( scan.status, scan.reason, scan.records, - snapshot.device, - snapshot.inode, + _sqlite_int(snapshot.device), + _sqlite_int(snapshot.inode), snapshot.size, snapshot.modified_ns, getattr(snapshot, "fingerprint", None), @@ -1625,8 +1625,8 @@ def _upsert_virtual_session( scan.status, scan.reason, scan.records, - snapshot.device, - snapshot.inode, + _sqlite_int(snapshot.device), + _sqlite_int(snapshot.inode), snapshot.size, snapshot.modified_ns, snapshot.fingerprint, @@ -3107,6 +3107,17 @@ def _is_non_negative_sqlite_int(value: Any) -> bool: return isinstance(value, int) and not isinstance(value, bool) and value >= 0 +def _sqlite_int(value: int) -> int: + """Fold an unsigned 64-bit stat identity into SQLite's signed INTEGER range. + + Windows reports st_dev (volume serial) and st_ino (file ID) as unsigned + 64-bit values, which SQLite rejects with OverflowError above 2**63 - 1. + """ + + value &= (1 << 64) - 1 + return value - (1 << 64) if value >= 1 << 63 else value + + def _copilot_native_metadata(root: Path) -> _NativeMetadata: """Read only bounded picker names, never event/message content.""" diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 9da67b1..d60c760 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -7,6 +7,7 @@ import pytest import session_migrate.catalog as catalog_module +import session_migrate.jsonl as jsonl_module from session_migrate.catalog import Catalog, auto_roots, default_catalog_path, discover_roots from session_migrate.errors import JsonlError, SessionMigrateError from session_migrate.formats import ( @@ -463,6 +464,38 @@ def test_refresh_is_incremental_and_validation_is_explicit(tmp_path: Path) -> No assert catalog.list_sessions()[0].reason is None +def test_refresh_stores_unsigned_64_bit_stat_identities( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Windows reports st_dev/st_ino as unsigned 64-bit values that can exceed + # SQLite's signed INTEGER range. + home = tmp_path / "claude" + session = home / "projects" / "-synthetic" / f"{CLAUDE_ID}.jsonl" + _write_jsonl(session, _claude_records(CLAUDE_ID, "Unsigned identity")) + real_snapshot = jsonl_module.file_snapshot + + def unsigned_snapshot(path: Path) -> jsonl_module.FileSnapshot: + snapshot = real_snapshot(path) + return jsonl_module.FileSnapshot( + device=2**64 - 1, + inode=2**63 + 7, + size=snapshot.size, + modified_ns=snapshot.modified_ns, + ) + + monkeypatch.setattr(catalog_module, "file_snapshot", unsigned_snapshot) + monkeypatch.setattr(jsonl_module, "file_snapshot", unsigned_snapshot) + + with _catalog(tmp_path) as catalog: + first = catalog.refresh(claude_roots=(home,), include_auto=False) + assert first.root_errors == 0 + assert first.scanned == 1 + second = catalog.refresh(include_auto=False) + assert second.root_errors == 0 + assert second.scanned == 0 + assert second.unchanged == 1 + + def test_pi_sessions_are_named_searchable_validatable_and_version_guarded( tmp_path: Path, ) -> None: