Add global --read-only mode in CLI

This commit is contained in:
Yiorgis Gozadinos 2025-12-18 16:42:03 +02:00
parent 1a8f72f99a
commit 98727ad080
No known key found for this signature in database
7 changed files with 235 additions and 92 deletions

View file

@ -1,6 +1,14 @@
# Changelog # Changelog
## [Unreleased] ## [Unreleased]
### Added
- **Read-Only Mode**: Global `--read-only` CLI flag for safe database access without modifications
- Blocks all write operations at the Store layer
- Skips database upgrades and settings saves on open
- Excludes write tools (`add_document_*`, `delete_document`) from MCP server
- Disables file monitor with warning when `--read-only` is used with `serve --monitor`
## [0.21.0] - 2025-12-18 ## [0.21.0] - 2025-12-18
### Added ### Added

View file

@ -35,9 +35,12 @@ logger = logging.getLogger(__name__)
class HaikuRAGApp: class HaikuRAGApp:
def __init__(self, db_path: Path, config: AppConfig = Config): def __init__(
self, db_path: Path, config: AppConfig = Config, read_only: bool = False
):
self.db_path = db_path self.db_path = db_path
self.config = config self.config = config
self.read_only = read_only
self.console = Console() self.console = Console()
async def init(self): async def init(self):
@ -213,13 +216,17 @@ class HaikuRAGApp:
) )
async def list_documents(self, filter: str | None = None): async def list_documents(self, filter: str | None = None):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
documents = await self.client.list_documents(filter=filter) documents = await self.client.list_documents(filter=filter)
for doc in documents: for doc in documents:
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
async def add_document_from_text(self, text: str, metadata: dict | None = None): async def add_document_from_text(self, text: str, metadata: dict | None = None):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
doc = await self.client.create_document(text, metadata=metadata) doc = await self.client.create_document(text, metadata=metadata)
self._rich_print_document(doc, truncate=True) self._rich_print_document(doc, truncate=True)
self.console.print( self.console.print(
@ -229,7 +236,9 @@ class HaikuRAGApp:
async def add_document_from_source( async def add_document_from_source(
self, source: str, title: str | None = None, metadata: dict | None = None self, source: str, title: str | None = None, metadata: dict | None = None
): ):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
result = await self.client.create_document_from_source( result = await self.client.create_document_from_source(
source, title=title, metadata=metadata source, title=title, metadata=metadata
) )
@ -246,7 +255,9 @@ class HaikuRAGApp:
) )
async def get_document(self, doc_id: str): async def get_document(self, doc_id: str):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
doc = await self.client.get_document_by_id(doc_id) doc = await self.client.get_document_by_id(doc_id)
if doc is None: if doc is None:
self.console.print(f"[red]Document with id {doc_id} not found.[/red]") self.console.print(f"[red]Document with id {doc_id} not found.[/red]")
@ -254,7 +265,9 @@ class HaikuRAGApp:
self._rich_print_document(doc, truncate=False) self._rich_print_document(doc, truncate=False)
async def delete_document(self, doc_id: str): async def delete_document(self, doc_id: str):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
deleted = await self.client.delete_document(doc_id) deleted = await self.client.delete_document(doc_id)
if deleted: if deleted:
self.console.print( self.console.print(
@ -268,7 +281,9 @@ class HaikuRAGApp:
async def search( async def search(
self, query: str, limit: int | None = None, filter: str | None = None self, query: str, limit: int | None = None, filter: str | None = None
): ):
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
results = await self.client.search(query, limit=limit, filter=filter) results = await self.client.search(query, limit=limit, filter=filter)
if not results: if not results:
self.console.print("[yellow]No results found.[/yellow]") self.console.print("[yellow]No results found.[/yellow]")
@ -280,7 +295,9 @@ class HaikuRAGApp:
"""Display visual grounding images for a chunk.""" """Display visual grounding images for a chunk."""
from textual_image.renderable import Image as RichImage from textual_image.renderable import Image as RichImage
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
chunk = await self.client.chunk_repository.get_by_id(chunk_id) chunk = await self.client.chunk_repository.get_by_id(chunk_id)
if not chunk: if not chunk:
self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]") self.console.print(f"[red]Chunk with id {chunk_id} not found.[/red]")
@ -325,7 +342,9 @@ class HaikuRAGApp:
verbose: Show verbose output verbose: Show verbose output
filter: SQL WHERE clause to filter documents filter: SQL WHERE clause to filter documents
""" """
async with HaikuRAG(db_path=self.db_path, config=self.config) as self.client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as self.client:
try: try:
citations = [] citations = []
if deep: if deep:
@ -394,7 +413,9 @@ class HaikuRAGApp:
verbose: Show AG-UI event stream during execution verbose: Show AG-UI event stream during execution
filter: SQL WHERE clause to filter documents filter: SQL WHERE clause to filter documents
""" """
async with HaikuRAG(db_path=self.db_path, config=self.config) as client: async with HaikuRAG(
db_path=self.db_path, config=self.config, read_only=self.read_only
) as client:
try: try:
self.console.print("[bold cyan]Starting research[/bold cyan]") self.console.print("[bold cyan]Starting research[/bold cyan]")
self.console.print(f"[bold blue]Question:[/bold blue] {question}") self.console.print(f"[bold blue]Question:[/bold blue] {question}")
@ -485,7 +506,10 @@ class HaikuRAGApp:
async def rebuild(self, mode: RebuildMode = RebuildMode.FULL): async def rebuild(self, mode: RebuildMode = RebuildMode.FULL):
async with HaikuRAG( async with HaikuRAG(
db_path=self.db_path, config=self.config, skip_validation=True db_path=self.db_path,
config=self.config,
skip_validation=True,
read_only=self.read_only,
) as client: ) as client:
try: try:
documents = await client.list_documents() documents = await client.list_documents()
@ -521,7 +545,10 @@ class HaikuRAGApp:
"""Run database maintenance: optimize and cleanup table history.""" """Run database maintenance: optimize and cleanup table history."""
try: try:
async with HaikuRAG( async with HaikuRAG(
db_path=self.db_path, config=self.config, skip_validation=True db_path=self.db_path,
config=self.config,
skip_validation=True,
read_only=self.read_only,
) as client: ) as client:
await client.vacuum() await client.vacuum()
self.console.print( self.console.print(
@ -534,7 +561,10 @@ class HaikuRAGApp:
"""Create vector index on the chunks table.""" """Create vector index on the chunks table."""
try: try:
async with HaikuRAG( async with HaikuRAG(
db_path=self.db_path, config=self.config, skip_validation=True db_path=self.db_path,
config=self.config,
skip_validation=True,
read_only=self.read_only,
) as client: ) as client:
row_count = client.store.chunks_table.count_rows() row_count = client.store.chunks_table.count_rows()
self.console.print(f"Chunks in database: {row_count}") self.console.print(f"Chunks in database: {row_count}")
@ -704,18 +734,27 @@ class HaikuRAGApp:
enable_agui: bool = False, enable_agui: bool = False,
): ):
"""Start the server with selected services.""" """Start the server with selected services."""
async with HaikuRAG(self.db_path, config=self.config) as client: async with HaikuRAG(
self.db_path, config=self.config, read_only=self.read_only
) as client:
tasks = [] tasks = []
# Start file monitor if enabled # Start file monitor if enabled (not available in read-only mode)
if enable_monitor: if enable_monitor:
monitor = FileWatcher(client=client, config=self.config) if self.read_only:
monitor_task = asyncio.create_task(monitor.observe()) logger.warning(
tasks.append(monitor_task) "File monitor disabled: cannot monitor files in read-only mode"
)
else:
monitor = FileWatcher(client=client, config=self.config)
monitor_task = asyncio.create_task(monitor.observe())
tasks.append(monitor_task)
# Start MCP server if enabled # Start MCP server if enabled
if enable_mcp: if enable_mcp:
server = create_mcp_server(self.db_path, config=self.config) server = create_mcp_server(
self.db_path, config=self.config, read_only=self.read_only
)
async def run_mcp(): async def run_mcp():
if mcp_transport == "stdio": if mcp_transport == "stdio":

View file

@ -26,6 +26,9 @@ cli = typer.Typer(
context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True
) )
# Module-level read-only flag set by callback
_read_only: bool = False
def create_app(db: Path | None = None) -> HaikuRAGApp: def create_app(db: Path | None = None) -> HaikuRAGApp:
"""Create HaikuRAGApp with loaded config and resolved database path. """Create HaikuRAGApp with loaded config and resolved database path.
@ -38,7 +41,7 @@ def create_app(db: Path | None = None) -> HaikuRAGApp:
""" """
config = get_config() config = get_config()
db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb" db_path = db if db else config.storage.data_dir / "haiku.rag.lancedb"
return HaikuRAGApp(db_path=db_path, config=config) return HaikuRAGApp(db_path=db_path, config=config, read_only=_read_only)
async def check_version(): async def check_version():
@ -72,8 +75,15 @@ def main(
"--config", "--config",
help="Path to YAML configuration file", help="Path to YAML configuration file",
), ),
read_only: bool = typer.Option(
False,
"--read-only",
help="Open database in read-only mode",
),
): ):
"""haiku.rag CLI - Vector database RAG system""" """haiku.rag CLI - Vector database RAG system"""
global _read_only
_read_only = read_only
# Load config from --config, local folder, or default directory # Load config from --config, local folder, or default directory
config_path = find_config_file(cli_path=config) config_path = find_config_file(cli_path=config)
if config_path: if config_path:
@ -353,7 +363,7 @@ def research(
from haiku.rag.cli_chat import interactive_research from haiku.rag.cli_chat import interactive_research
from haiku.rag.client import HaikuRAG from haiku.rag.client import HaikuRAG
client = HaikuRAG(db_path=app.db_path, config=app.config) client = HaikuRAG(db_path=app.db_path, config=app.config, read_only=_read_only)
try: try:
interactive_research( interactive_research(
client=client, client=client,
@ -521,7 +531,7 @@ def inspect(
raise typer.Exit(1) from e raise typer.Exit(1) from e
db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb" db_path = db if db else get_config().storage.data_dir / "haiku.rag.lancedb"
run_inspector(db_path) run_inspector(db_path, read_only=_read_only)
@cli.command( @cli.command(

View file

@ -60,6 +60,7 @@ class HaikuRAG:
config: AppConfig = Config, config: AppConfig = Config,
skip_validation: bool = False, skip_validation: bool = False,
create: bool = False, create: bool = False,
read_only: bool = False,
): ):
"""Initialize the RAG client with a database path. """Initialize the RAG client with a database path.
@ -68,6 +69,7 @@ class HaikuRAG:
config: Configuration to use. Defaults to global Config. config: Configuration to use. Defaults to global Config.
skip_validation: Whether to skip configuration validation on database load. skip_validation: Whether to skip configuration validation on database load.
create: Whether to create the database if it doesn't exist. create: Whether to create the database if it doesn't exist.
read_only: Whether to open the database in read-only mode.
""" """
self._config = config self._config = config
if db_path is None: if db_path is None:
@ -77,10 +79,16 @@ class HaikuRAG:
config=self._config, config=self._config,
skip_validation=skip_validation, skip_validation=skip_validation,
create=create, create=create,
read_only=read_only,
) )
self.document_repository = DocumentRepository(self.store) self.document_repository = DocumentRepository(self.store)
self.chunk_repository = ChunkRepository(self.store) self.chunk_repository = ChunkRepository(self.store)
@property
def is_read_only(self) -> bool:
"""Whether the client is in read-only mode."""
return self.store.is_read_only
async def __aenter__(self): async def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
return self return self

View file

@ -74,9 +74,10 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
Binding("c", "show_context", "Context", show=True), Binding("c", "show_context", "Context", show=True),
] ]
def __init__(self, db_path: Path): def __init__(self, db_path: Path, read_only: bool = False):
super().__init__() super().__init__()
self.db_path = db_path self.db_path = db_path
self.read_only = read_only
self.client: HaikuRAG | None = None self.client: HaikuRAG | None = None
def compose(self) -> "ComposeResult": def compose(self) -> "ComposeResult":
@ -90,7 +91,9 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Initialize the app when mounted.""" """Initialize the app when mounted."""
config = get_config() config = get_config()
self.client = HaikuRAG(db_path=self.db_path, config=config) self.client = HaikuRAG(
db_path=self.db_path, config=config, read_only=self.read_only
)
await self.client.__aenter__() await self.client.__aenter__()
# Load initial documents # Load initial documents
@ -229,15 +232,18 @@ class InspectorApp(App): # type: ignore[misc] # pragma: no cover
await self._switch_modal(ContextModal(chunk=chunk, client=self.client)) await self._switch_modal(ContextModal(chunk=chunk, client=self.client))
def run_inspector(db_path: Path | None = None) -> None: # pragma: no cover def run_inspector(
db_path: Path | None = None, read_only: bool = False
) -> None: # pragma: no cover
"""Run the inspector TUI. """Run the inspector TUI.
Args: Args:
db_path: Path to the LanceDB database. If None, uses default from config. db_path: Path to the LanceDB database. If None, uses default from config.
read_only: Whether to open the database in read-only mode.
""" """
config = get_config() config = get_config()
if db_path is None: if db_path is None:
db_path = config.storage.data_dir / "haiku.rag.lancedb" db_path = config.storage.data_dir / "haiku.rag.lancedb"
app = InspectorApp(db_path) app = InspectorApp(db_path, read_only=read_only)
app.run() app.run()

View file

@ -21,70 +21,91 @@ class DocumentResult(BaseModel):
updated_at: str updated_at: str
def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP: def create_mcp_server(
"""Create an MCP server with the specified database path.""" db_path: Path, config: AppConfig = Config, read_only: bool = False
) -> FastMCP:
"""Create an MCP server with the specified database path.
Args:
db_path: Path to the database file.
config: Configuration to use.
read_only: If True, write tools (add_document_*, delete_document) are not registered.
"""
mcp = FastMCP("haiku-rag") mcp = FastMCP("haiku-rag")
@mcp.tool() # Write tools - only registered when not in read-only mode
async def add_document_from_file( if not read_only:
file_path: str,
metadata: dict[str, Any] | None = None,
title: str | None = None,
) -> str | None:
"""Add a document to the RAG system from a file path."""
try:
async with HaikuRAG(db_path, config=config) as rag:
result = await rag.create_document_from_source(
Path(file_path), title=title, metadata=metadata or {}
)
# Handle both single document and list of documents (directories)
if isinstance(result, list):
return result[0].id if result else None
return result.id
except Exception:
return None
@mcp.tool() @mcp.tool()
async def add_document_from_url( async def add_document_from_file(
url: str, metadata: dict[str, Any] | None = None, title: str | None = None file_path: str,
) -> str | None: metadata: dict[str, Any] | None = None,
"""Add a document to the RAG system from a URL.""" title: str | None = None,
try: ) -> str | None:
async with HaikuRAG(db_path, config=config) as rag: """Add a document to the RAG system from a file path."""
result = await rag.create_document_from_source( try:
url, title=title, metadata=metadata or {} async with HaikuRAG(db_path, config=config) as rag:
) result = await rag.create_document_from_source(
# Handle both single document and list of documents Path(file_path), title=title, metadata=metadata or {}
if isinstance(result, list): )
return result[0].id if result else None # Handle both single document and list of documents (directories)
return result.id if isinstance(result, list):
except Exception: return result[0].id if result else None
return None return result.id
except Exception:
return None
@mcp.tool() @mcp.tool()
async def add_document_from_text( async def add_document_from_url(
content: str, url: str, metadata: dict[str, Any] | None = None, title: str | None = None
uri: str | None = None, ) -> str | None:
metadata: dict[str, Any] | None = None, """Add a document to the RAG system from a URL."""
title: str | None = None, try:
) -> str | None: async with HaikuRAG(db_path, config=config) as rag:
"""Add a document to the RAG system from text content.""" result = await rag.create_document_from_source(
try: url, title=title, metadata=metadata or {}
async with HaikuRAG(db_path, config=config) as rag: )
document = await rag.create_document( # Handle both single document and list of documents
content, uri, title=title, metadata=metadata or {} if isinstance(result, list):
) return result[0].id if result else None
return document.id return result.id
except Exception: except Exception:
return None return None
@mcp.tool()
async def add_document_from_text(
content: str,
uri: str | None = None,
metadata: dict[str, Any] | None = None,
title: str | None = None,
) -> str | None:
"""Add a document to the RAG system from text content."""
try:
async with HaikuRAG(db_path, config=config) as rag:
document = await rag.create_document(
content, uri, title=title, metadata=metadata or {}
)
return document.id
except Exception:
return None
@mcp.tool()
async def delete_document(document_id: str) -> bool:
"""Delete a document by its ID."""
try:
async with HaikuRAG(db_path, config=config) as rag:
return await rag.delete_document(document_id)
except Exception:
return False
# Read tools - always registered
@mcp.tool() @mcp.tool()
async def search_documents( async def search_documents(
query: str, limit: int | None = None query: str, limit: int | None = None
) -> list[SearchResult]: ) -> list[SearchResult]:
"""Search the RAG system for documents using hybrid search (vector similarity + full-text search).""" """Search the RAG system for documents using hybrid search (vector similarity + full-text search)."""
try: try:
async with HaikuRAG(db_path, config=config) as rag: async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
return await rag.search(query, limit=limit) return await rag.search(query, limit=limit)
except Exception: except Exception:
return [] return []
@ -93,7 +114,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
async def get_document(document_id: str) -> DocumentResult | None: async def get_document(document_id: str) -> DocumentResult | None:
"""Get a document by its ID.""" """Get a document by its ID."""
try: try:
async with HaikuRAG(db_path, config=config) as rag: async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
document = await rag.get_document_by_id(document_id) document = await rag.get_document_by_id(document_id)
if document is None: if document is None:
@ -128,7 +149,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
List of DocumentResult instances matching the criteria. List of DocumentResult instances matching the criteria.
""" """
try: try:
async with HaikuRAG(db_path, config=config) as rag: async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
documents = await rag.list_documents(limit, offset, filter) documents = await rag.list_documents(limit, offset, filter)
return [ return [
@ -146,15 +167,6 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
except Exception: except Exception:
return [] return []
@mcp.tool()
async def delete_document(document_id: str) -> bool:
"""Delete a document by its ID."""
try:
async with HaikuRAG(db_path, config=config) as rag:
return await rag.delete_document(document_id)
except Exception:
return False
@mcp.tool() @mcp.tool()
async def ask_question( async def ask_question(
question: str, question: str,
@ -172,7 +184,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
The answer as a string. The answer as a string.
""" """
try: try:
async with HaikuRAG(db_path, config=config) as rag: async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
if deep: if deep:
from haiku.rag.graph.research.dependencies import ResearchContext from haiku.rag.graph.research.dependencies import ResearchContext
from haiku.rag.graph.research.graph import build_research_graph from haiku.rag.graph.research.graph import build_research_graph
@ -222,7 +234,7 @@ def create_mcp_server(db_path: Path, config: AppConfig = Config) -> FastMCP:
from haiku.rag.graph.research.graph import build_research_graph from haiku.rag.graph.research.graph import build_research_graph
from haiku.rag.graph.research.state import ResearchDeps, ResearchState from haiku.rag.graph.research.state import ResearchDeps, ResearchState
async with HaikuRAG(db_path, config=config) as rag: async with HaikuRAG(db_path, config=config, read_only=read_only) as rag:
graph = build_research_graph(config=config) graph = build_research_graph(config=config)
context = ResearchContext(original_question=question) context = ResearchContext(original_question=question)
state = ResearchState.from_config(context=context, config=config) state = ResearchState.from_config(context=context, config=config)

View file

@ -1,5 +1,6 @@
import pytest import pytest
from haiku.rag.client import HaikuRAG
from haiku.rag.store import ReadOnlyError, Store from haiku.rag.store import ReadOnlyError, Store
from haiku.rag.store.models import Chunk, Document from haiku.rag.store.models import Chunk, Document
from haiku.rag.store.repositories.chunk import ChunkRepository from haiku.rag.store.repositories.chunk import ChunkRepository
@ -227,3 +228,62 @@ class TestSettingsRepositoryReadOnly:
with pytest.raises(ReadOnlyError): with pytest.raises(ReadOnlyError):
repo.save_current_settings() repo.save_current_settings()
store.close() store.close()
class TestClientReadOnly:
def test_client_default_is_not_read_only(self, temp_db_path):
"""Client defaults to not read-only."""
client = HaikuRAG(temp_db_path, create=True)
assert client.is_read_only is False
client.close()
def test_client_can_be_created_read_only(self, temp_db_path):
"""Client can be created with read_only=True."""
client = HaikuRAG(temp_db_path, create=True)
client.close()
client = HaikuRAG(temp_db_path, read_only=True)
assert client.is_read_only is True
client.close()
@pytest.mark.asyncio
async def test_client_create_document_raises_when_read_only(self, temp_db_path):
"""Client.create_document() raises ReadOnlyError when read_only=True."""
client = HaikuRAG(temp_db_path, create=True)
client.close()
async with HaikuRAG(temp_db_path, read_only=True) as client:
with pytest.raises(ReadOnlyError):
await client.create_document("test content")
@pytest.mark.asyncio
async def test_client_delete_document_raises_when_read_only(self, temp_db_path):
"""Client.delete_document() raises ReadOnlyError when read_only=True."""
async with HaikuRAG(temp_db_path, create=True) as client:
doc = await client.create_document("test content")
assert doc.id is not None
doc_id = doc.id
async with HaikuRAG(temp_db_path, read_only=True) as client:
with pytest.raises(ReadOnlyError):
await client.delete_document(doc_id)
@pytest.mark.asyncio
async def test_client_search_works_when_read_only(self, temp_db_path):
"""Client.search() works in read-only mode."""
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document("test content about cats")
async with HaikuRAG(temp_db_path, read_only=True) as client:
results = await client.search("cats")
assert len(results) > 0
@pytest.mark.asyncio
async def test_client_list_documents_works_when_read_only(self, temp_db_path):
"""Client.list_documents() works in read-only mode."""
async with HaikuRAG(temp_db_path, create=True) as client:
await client.create_document("test content")
async with HaikuRAG(temp_db_path, read_only=True) as client:
docs = await client.list_documents()
assert len(docs) == 1