From 4d841c8a40b10cb7d6998de8781ce0274dd201ba Mon Sep 17 00:00:00 2001 From: Marco Porcellato Date: Mon, 29 Jun 2026 19:19:10 +0200 Subject: [PATCH] fix(agent): handle corrupt X-Ray state files without crashing agent-write Harden SessionAliasRegistry.load_from_disk for empty, invalid JSON, legacy wrapper keys, and non-integer alias keys. KINETIC agent-write catches SessionAliasRegistryError and replaces the production assert guard. Closes #60 Co-authored-by: Cursor --- CHANGELOG.md | 4 ++ docs/ARCHITECTURE.md | 2 +- src/logseq_matryca_parser/agent_press.py | 61 ++++++++++++++++++++++-- src/logseq_matryca_parser/exceptions.py | 4 ++ src/logseq_matryca_parser/kinetic.py | 12 ++++- tests/test_agent_press.py | 45 +++++++++++++++++ tests/test_kinetic.py | 25 ++++++++++ 7 files changed, 146 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3899bc8..6776780 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **agent-write** — `SessionAliasRegistry.load_from_disk` tolerates empty, malformed, or legacy-wrapped X-Ray JSON; KINETIC exits with a clear message instead of a traceback ([#60](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/60)). + ### Added - **Contributor issues (wave 3)** — Six new issues from local code study ([#59](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/59)–[#64](https://github.com/MarcoPorcellato/logseq-matryca-parser/issues/64)): LENS ghost wikilink nodes, corrupt X-Ray state handling, `agent_write` assert guard, and paired good-first tests. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b967f04..8041163 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -374,7 +374,7 @@ Human-facing RAG (SYNAPSE enriched chunks, breadcrumbs, inherited properties) op #### Stateful session registry (`.matryca_xray_state.json`) -X-Ray is designed for **stateless LLM toolchains**: each `agent-read` invocation is a fresh process, yet agents must still **write back** to blocks they only saw as `[n]` tokens. After **`generate_aliases`**, **KINETIC** persists the alias map to **`/.matryca_xray_state.json`** via **`SessionAliasRegistry.save_to_disk`**. A later **`matryca-parse agent-write --alias N`** loads that JSON with **`load_from_disk`**, resolves `N → uuid`, and hands off to **`append_child_to_node`**. The read/write pair therefore composes as **two independent CLI exits** without stuffing UUIDs into the model context — the filesystem holds session continuity. +X-Ray is designed for **stateless LLM toolchains**: each `agent-read` invocation is a fresh process, yet agents must still **write back** to blocks they only saw as `[n]` tokens. After **`generate_aliases`**, **KINETIC** persists the alias map to **`/.matryca_xray_state.json`** via **`SessionAliasRegistry.save_to_disk`**. A later **`matryca-parse agent-write --alias N`** loads that JSON with **`load_from_disk`**, resolves `N → uuid`, and hands off to **`append_child_to_node`**. Malformed or empty state files yield a controlled CLI exit (empty registry for whitespace-only files; **`SessionAliasRegistryError`** for invalid JSON) rather than an uncaught traceback. The read/write pair therefore composes as **two independent CLI exits** without stuffing UUIDs into the model context — the filesystem holds session continuity. #### Session alias mechanics (`SessionAliasRegistry`) diff --git a/src/logseq_matryca_parser/agent_press.py b/src/logseq_matryca_parser/agent_press.py index 6b3b945..5b35ec8 100644 --- a/src/logseq_matryca_parser/agent_press.py +++ b/src/logseq_matryca_parser/agent_press.py @@ -5,7 +5,9 @@ import json import logging from pathlib import Path +from typing import Any +from logseq_matryca_parser.exceptions import SessionAliasRegistryError from logseq_matryca_parser.logos_core import LogseqNode logger = logging.getLogger(__name__) @@ -31,6 +33,22 @@ def walk(node: LogseqNode) -> None: return flat +def _normalize_alias_payload(raw: Any) -> dict[str, Any]: + """Coerce on-disk JSON into a flat ``alias_str -> uuid`` mapping.""" + if not isinstance(raw, dict): + msg = "X-Ray state file must contain a JSON object mapping aliases to UUIDs" + raise SessionAliasRegistryError(msg) + if set(raw.keys()) == {"aliases"} and isinstance(raw.get("aliases"), dict): + logger.warning( + "SessionAliasRegistry.load_from_disk: unwrap legacy 'aliases' wrapper key" + ) + raw = raw["aliases"] + if not isinstance(raw, dict): + msg = "X-Ray state 'aliases' value must be a JSON object" + raise SessionAliasRegistryError(msg) + return raw + + class SessionAliasRegistry: """Maps sequential integer aliases to Logseq block UUIDs for a single agent session.""" @@ -69,11 +87,43 @@ def save_to_disk(self, filepath: Path) -> None: @classmethod def load_from_disk(cls, filepath: Path) -> SessionAliasRegistry: """Reconstruct a registry from a JSON file written by :meth:`save_to_disk`.""" - raw = json.loads(filepath.read_text(encoding="utf-8")) + raw_text = filepath.read_text(encoding="utf-8").strip() + if not raw_text: + logger.warning("SessionAliasRegistry.load_from_disk: empty state file %s", filepath) + return cls() + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError as exc: + msg = f"Invalid JSON in X-Ray state file: {filepath}" + raise SessionAliasRegistryError(msg) from exc + raw = _normalize_alias_payload(parsed) registry = cls() seen_uuids: set[str] = set() - for alias_str in sorted(raw.keys(), key=int): + ordered_aliases: list[int] = [] + for alias_str in raw: + if not isinstance(alias_str, str): + logger.warning( + "SessionAliasRegistry.load_from_disk: skip non-string alias key=%r", + alias_str, + ) + continue + try: + ordered_aliases.append(int(alias_str)) + except ValueError: + logger.warning( + "SessionAliasRegistry.load_from_disk: skip non-integer alias key=%s", + alias_str, + ) + + for alias in sorted(ordered_aliases): + alias_str = str(alias) node_uuid = raw[alias_str] + if not isinstance(node_uuid, str) or not node_uuid.strip(): + logger.warning( + "SessionAliasRegistry.load_from_disk: skip invalid uuid for alias=%s", + alias_str, + ) + continue if node_uuid in seen_uuids: logger.warning( "SessionAliasRegistry.load_from_disk: skip duplicate uuid=%s alias=%s", @@ -81,11 +131,14 @@ def load_from_disk(cls, filepath: Path) -> SessionAliasRegistry: alias_str, ) continue - alias = int(alias_str) seen_uuids.add(node_uuid) registry._alias_to_uuid[alias] = node_uuid registry._uuid_to_alias[node_uuid] = alias - logger.debug("SessionAliasRegistry loaded %s aliases from %s", len(registry._alias_to_uuid), filepath) + logger.debug( + "SessionAliasRegistry loaded %s aliases from %s", + len(registry._alias_to_uuid), + filepath, + ) return registry diff --git a/src/logseq_matryca_parser/exceptions.py b/src/logseq_matryca_parser/exceptions.py index 71a9f7a..85b39ad 100644 --- a/src/logseq_matryca_parser/exceptions.py +++ b/src/logseq_matryca_parser/exceptions.py @@ -11,3 +11,7 @@ class LogseqIndentationError(LogseqParserError): class BlockReferenceError(LogseqParserError): """Raised when a block reference cannot be resolved.""" + + +class SessionAliasRegistryError(Exception): + """Raised when the X-Ray alias state file cannot be parsed or validated.""" diff --git a/src/logseq_matryca_parser/kinetic.py b/src/logseq_matryca_parser/kinetic.py index 43ec680..dec46ba 100644 --- a/src/logseq_matryca_parser/kinetic.py +++ b/src/logseq_matryca_parser/kinetic.py @@ -641,13 +641,21 @@ def agent_write( if not registry_path.is_file(): print(f"Alias state file not found: {registry_path}", file=sys.stderr) raise typer.Exit(code=1) - registry = SessionAliasRegistry.load_from_disk(registry_path) + from logseq_matryca_parser.exceptions import SessionAliasRegistryError + + try: + registry = SessionAliasRegistry.load_from_disk(registry_path) + except SessionAliasRegistryError as exc: + print(str(exc), file=sys.stderr) + raise typer.Exit(code=1) from exc parent_uuid = registry.resolve_alias(alias) if parent_uuid is None: print(f"Unknown alias: {alias}", file=sys.stderr) raise typer.Exit(code=1) - assert parent_uuid is not None + if parent_uuid is None: + print("Parent block UUID could not be resolved.", file=sys.stderr) + raise typer.Exit(code=1) try: append_child_to_node(graph, parent_uuid, content) except ValueError as exc: diff --git a/tests/test_agent_press.py b/tests/test_agent_press.py index b61d1ce..f15d96a 100644 --- a/tests/test_agent_press.py +++ b/tests/test_agent_press.py @@ -4,6 +4,7 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from logseq_matryca_parser.agent_press import SessionAliasRegistry, to_xray_markdown @@ -81,6 +82,50 @@ def test_session_alias_registry_load_skips_duplicate_uuids(tmp_path: Path) -> No assert registry.alias_for_uuid("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") == 0 +def test_session_alias_registry_load_empty_file(tmp_path: Path) -> None: + state_path = tmp_path / "alias_state.json" + state_path.write_text("", encoding="utf-8") + + registry = SessionAliasRegistry.load_from_disk(state_path) + + assert registry.resolve_alias(0) is None + + +def test_session_alias_registry_load_invalid_json(tmp_path: Path) -> None: + from logseq_matryca_parser.exceptions import SessionAliasRegistryError + + state_path = tmp_path / "alias_state.json" + state_path.write_text("{not json", encoding="utf-8") + + with pytest.raises(SessionAliasRegistryError, match="Invalid JSON"): + SessionAliasRegistry.load_from_disk(state_path) + + +def test_session_alias_registry_load_skips_non_integer_alias_keys(tmp_path: Path) -> None: + state_path = tmp_path / "alias_state.json" + state_path.write_text( + '{"abc": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "0": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}', + encoding="utf-8", + ) + + registry = SessionAliasRegistry.load_from_disk(state_path) + + assert registry.resolve_alias(0) == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + assert registry.resolve_alias(1) is None + + +def test_session_alias_registry_load_unwraps_aliases_wrapper(tmp_path: Path) -> None: + state_path = tmp_path / "alias_state.json" + state_path.write_text( + '{"aliases": {"0": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}', + encoding="utf-8", + ) + + registry = SessionAliasRegistry.load_from_disk(state_path) + + assert registry.resolve_alias(0) == "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + + def test_to_xray_markdown_nested_properties_stripped() -> None: child_one = _make_node( "dddddddd-dddd-dddd-dddd-dddddddddddd", diff --git a/tests/test_kinetic.py b/tests/test_kinetic.py index 45081b9..2dd8a0e 100644 --- a/tests/test_kinetic.py +++ b/tests/test_kinetic.py @@ -512,3 +512,28 @@ def test_agent_write_missing_state_file_exits_nonzero(tmp_path: Path) -> None: ], ) assert result.exit_code == 1 + + +def test_agent_write_corrupt_state_file_exits_nonzero(tmp_path: Path) -> None: + """Malformed X-Ray JSON must exit 1 with a clear message (#60).""" + graph_root = tmp_path / "graph" + pages = graph_root / "pages" + pages.mkdir(parents=True) + (graph_root / "journals").mkdir() + state_path = graph_root / ".matryca_xray_state.json" + state_path.write_text("{not json", encoding="utf-8") + + result = runner.invoke( + app, + [ + "agent-write", + str(graph_root), + "--content", + "x", + "--alias", + "0", + ], + ) + + assert result.exit_code == 1 + assert "Invalid JSON" in (result.stderr or result.output)