fix(tui): implement SQLite session persistence at ~/.local/state/cleveragents/tui.db #10648
@@ -0,0 +1,212 @@
|
||||
"""Step definitions for TUI session persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.tui.app import SessionView
|
||||
from cleveragents.tui.persistence import TuiSessionStore
|
||||
|
||||
|
||||
@given("a clean TUI session database")
|
||||
def step_clean_database(context: object) -> None:
|
||||
"""Create a clean temporary database for testing."""
|
||||
# Create a temporary directory for the test database
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
db_path = Path(temp_dir) / "tui.db"
|
||||
|
||||
# Store in context for later use
|
||||
context.temp_dir = temp_dir # type: ignore
|
||||
context.db_path = db_path # type: ignore
|
||||
context.store = TuiSessionStore(db_path=db_path) # type: ignore
|
||||
|
||||
|
||||
@when("the TUI session store is initialized")
|
||||
def step_initialize_store(context: object) -> None:
|
||||
"""Initialize the session store (already done in background)."""
|
||||
pass
|
||||
|
||||
|
||||
@then("the SQLite database file exists at ~/.local/state/cleveragents/tui.db")
|
||||
def step_database_exists(context: object) -> None:
|
||||
"""Verify the database file exists."""
|
||||
assert context.db_path.exists() # type: ignore
|
||||
|
||||
|
||||
@then("the database has sessions and active_session tables")
|
||||
def step_database_has_tables(context: object) -> None:
|
||||
"""Verify the database has the required tables."""
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(context.db_path) as conn: # type: ignore
|
||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in cursor.fetchall()}
|
||||
assert "sessions" in tables
|
||||
assert "active_session" in tables
|
||||
|
||||
|
||||
@given('a session with id "{session_id}" and transcript {transcript}')
|
||||
def step_create_session_with_transcript(
|
||||
context: object, session_id: str, transcript: str
|
||||
) -> None:
|
||||
"""Create a session with the given id and transcript."""
|
||||
# Parse the transcript list from the string
|
||||
import ast
|
||||
|
||||
transcript_list = ast.literal_eval(transcript)
|
||||
session = SessionView(session_id=session_id, transcript=transcript_list)
|
||||
context.session = session # type: ignore
|
||||
|
||||
|
||||
@given('a session with id "{session_id}"')
|
||||
def step_create_session(context: object, session_id: str) -> None:
|
||||
"""Create a session with the given id."""
|
||||
session = SessionView(session_id=session_id, transcript=[])
|
||||
context.session = session # type: ignore
|
||||
|
||||
|
||||
@when("I save the session to the database")
|
||||
def step_save_session(context: object) -> None:
|
||||
"""Save the session to the database."""
|
||||
context.store.save_session(context.session) # type: ignore
|
||||
|
||||
|
||||
@then("the session can be loaded from the database")
|
||||
def step_load_session(context: object) -> None:
|
||||
"""Load the session from the database."""
|
||||
loaded = context.store.load_session(context.session.session_id) # type: ignore
|
||||
assert loaded is not None
|
||||
context.loaded_session = loaded # type: ignore
|
||||
|
||||
|
||||
@then("the loaded session has the same id and transcript")
|
||||
def step_verify_session_data(context: object) -> None:
|
||||
"""Verify the loaded session matches the original."""
|
||||
assert context.loaded_session.session_id == context.session.session_id # type: ignore
|
||||
assert context.loaded_session.transcript == context.session.transcript # type: ignore
|
||||
|
||||
|
||||
@when("I set it as the active session")
|
||||
def step_set_active_session(context: object) -> None:
|
||||
"""Set the session as active."""
|
||||
context.store.set_active_session(context.session.session_id) # type: ignore
|
||||
|
||||
|
||||
@then('the active session id is "{session_id}"')
|
||||
def step_verify_active_session(context: object, session_id: str) -> None:
|
||||
"""Verify the active session id."""
|
||||
active = context.store.get_active_session() # type: ignore
|
||||
assert active == session_id
|
||||
|
||||
|
||||
@given("sessions with ids {session_ids}")
|
||||
def step_create_multiple_sessions(context: object, session_ids: str) -> None:
|
||||
"""Create multiple sessions."""
|
||||
import ast
|
||||
|
||||
ids = ast.literal_eval(session_ids)
|
||||
context.sessions = [ # type: ignore
|
||||
SessionView(session_id=sid, transcript=[]) for sid in ids
|
||||
]
|
||||
|
||||
|
||||
@when("I save all sessions to the database")
|
||||
def step_save_all_sessions(context: object) -> None:
|
||||
"""Save all sessions to the database."""
|
||||
for session in context.sessions: # type: ignore
|
||||
context.store.save_session(session) # type: ignore
|
||||
|
||||
|
||||
@then("listing sessions returns all three session ids")
|
||||
def step_verify_list_sessions(context: object) -> None:
|
||||
"""Verify all sessions are listed."""
|
||||
listed = context.store.list_sessions() # type: ignore
|
||||
expected_ids = {s.session_id for s in context.sessions} # type: ignore
|
||||
assert set(listed) == expected_ids
|
||||
|
||||
|
||||
@given('a session with id "{session_id}" in the database')
|
||||
def step_create_and_save_session(context: object, session_id: str) -> None:
|
||||
"""Create and save a session."""
|
||||
session = SessionView(session_id=session_id, transcript=[])
|
||||
context.store.save_session(session) # type: ignore
|
||||
context.session = session # type: ignore
|
||||
|
||||
|
||||
@when("I delete the session")
|
||||
def step_delete_session(context: object) -> None:
|
||||
"""Delete the session."""
|
||||
context.store.delete_session(context.session.session_id) # type: ignore
|
||||
|
||||
|
||||
@then("the session can no longer be loaded from the database")
|
||||
def step_verify_session_deleted(context: object) -> None:
|
||||
"""Verify the session is deleted."""
|
||||
loaded = context.store.load_session(context.session.session_id) # type: ignore
|
||||
assert loaded is None
|
||||
|
||||
|
||||
@when("I clear all sessions")
|
||||
def step_clear_all_sessions(context: object) -> None:
|
||||
"""Clear all sessions."""
|
||||
context.store.clear_all() # type: ignore
|
||||
|
||||
|
||||
@then("the database has no sessions")
|
||||
def step_verify_no_sessions(context: object) -> None:
|
||||
"""Verify there are no sessions."""
|
||||
listed = context.store.list_sessions() # type: ignore
|
||||
assert len(listed) == 0
|
||||
|
||||
|
||||
@then("there is no active session")
|
||||
def step_verify_no_active_session(context: object) -> None:
|
||||
"""Verify there is no active session."""
|
||||
active = context.store.get_active_session() # type: ignore
|
||||
assert active is None
|
||||
|
||||
|
||||
@when("I save the session and set it as active")
|
||||
def step_save_and_set_active(context: object) -> None:
|
||||
"""Save the session and set it as active."""
|
||||
context.store.save_session(context.session) # type: ignore
|
||||
context.store.set_active_session(context.session.session_id) # type: ignore
|
||||
|
||||
|
||||
@when("I create a new session store instance")
|
||||
def step_create_new_store_instance(context: object) -> None:
|
||||
"""Create a new session store instance."""
|
||||
context.new_store = TuiSessionStore(db_path=context.db_path) # type: ignore
|
||||
|
||||
|
||||
@then('the active session is "{session_id}"')
|
||||
def step_verify_new_store_active_session(context: object, session_id: str) -> None:
|
||||
"""Verify the active session in the new store."""
|
||||
active = context.new_store.get_active_session() # type: ignore
|
||||
assert active == session_id
|
||||
|
||||
|
||||
@then("the session data is restored correctly")
|
||||
def step_verify_session_restored(context: object) -> None:
|
||||
"""Verify the session data is restored correctly."""
|
||||
loaded = context.new_store.load_session(context.session.session_id) # type: ignore
|
||||
assert loaded is not None
|
||||
assert loaded.session_id == context.session.session_id # type: ignore
|
||||
assert loaded.transcript == context.session.transcript # type: ignore
|
||||
|
||||
|
||||
@given("the TUI session store is created with default database path")
|
||||
def step_default_db_path(context: object) -> None:
|
||||
"""Initialize store using the default path, patching home to a temp dir."""
|
||||
from unittest.mock import patch
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_home = Path(temp_dir)
|
||||
with patch.object(Path, "home", return_value=temp_home):
|
||||
context.store = TuiSessionStore() # type: ignore
|
||||
context.db_path = ( # type: ignore
|
||||
temp_home / ".local" / "state" / "cleveragents" / "tui.db"
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
Feature: TUI Session Persistence
|
||||
As a user
|
||||
I want my TUI session state to be persisted to SQLite
|
||||
So that I can resume my work when I restart the TUI
|
||||
|
||||
Background:
|
||||
Given a clean TUI session database
|
||||
|
||||
Scenario: Database is created on first launch
|
||||
When the TUI session store is initialized
|
||||
Then the SQLite database file exists at ~/.local/state/cleveragents/tui.db
|
||||
And the database has sessions and active_session tables
|
||||
|
||||
Scenario: Save and load a session
|
||||
Given a session with id "test-session" and transcript ["hello", "world"]
|
||||
When I save the session to the database
|
||||
Then the session can be loaded from the database
|
||||
And the loaded session has the same id and transcript
|
||||
|
||||
Scenario: Set and get active session
|
||||
Given a session with id "session-1"
|
||||
When I set it as the active session
|
||||
Then the active session id is "session-1"
|
||||
|
||||
Scenario: List all sessions
|
||||
Given sessions with ids ["session-1", "session-2", "session-3"]
|
||||
When I save all sessions to the database
|
||||
Then listing sessions returns all three session ids
|
||||
|
||||
Scenario: Delete a session
|
||||
Given a session with id "to-delete" in the database
|
||||
When I delete the session
|
||||
Then the session can no longer be loaded from the database
|
||||
|
||||
Scenario: Clear all sessions
|
||||
Given sessions with ids ["session-1", "session-2"]
|
||||
When I save all sessions to the database
|
||||
And I clear all sessions
|
||||
Then the database has no sessions
|
||||
And there is no active session
|
||||
|
||||
Scenario: Restore last active session on startup
|
||||
Given a session with id "last-session" and transcript ["msg1", "msg2"]
|
||||
When I save the session and set it as active
|
||||
And I create a new session store instance
|
||||
Then the active session is "last-session"
|
||||
And the session data is restored correctly
|
||||
|
||||
Scenario: Store uses default database path when none is specified
|
||||
Given the TUI session store is created with default database path
|
||||
Then the SQLite database file exists at ~/.local/state/cleveragents/tui.db
|
||||
@@ -0,0 +1,5 @@
|
||||
"""TUI session persistence module using SQLite."""
|
||||
|
||||
from cleveragents.tui.persistence.store import TuiSessionStore
|
||||
|
||||
__all__ = ["TuiSessionStore"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""SQLite-backed session store for TUI persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from cleveragents.tui.app import SessionView
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TuiSessionStore:
|
||||
"""Manages TUI session persistence using SQLite.
|
||||
|
||||
Database location: ~/.local/state/cleveragents/tui.db
|
||||
"""
|
||||
|
||||
db_path: Path
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
"""Initialize the session store.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database.
|
||||
Defaults to ~/.local/state/cleveragents/tui.db
|
||||
"""
|
||||
if db_path is None:
|
||||
state_dir = Path.home() / ".local" / "state" / "cleveragents"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
db_path = state_dir / "tui.db"
|
||||
|
||||
self.db_path = db_path
|
||||
self._initialize_db()
|
||||
|
||||
def _initialize_db(self) -> None:
|
||||
"""Initialize the SQLite database schema."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
transcript TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS active_session (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
session_id TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
def save_session(self, session: SessionView) -> None:
|
||||
"""Save a session to the database.
|
||||
|
||||
Args:
|
||||
session: The SessionView to save
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
transcript_json = json.dumps(session.transcript)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO sessions (session_id, transcript, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(session.session_id, transcript_json),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def load_session(self, session_id: str) -> SessionView | None:
|
||||
"""Load a session from the database.
|
||||
|
||||
Args:
|
||||
session_id: The ID of the session to load
|
||||
|
||||
Returns:
|
||||
The SessionView if found, None otherwise
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT session_id, transcript FROM sessions WHERE session_id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
session_id_result, transcript_json = row
|
||||
transcript = json.loads(transcript_json)
|
||||
# Import here to avoid circular imports
|
||||
from cleveragents.tui.app import SessionView
|
||||
|
||||
return SessionView(session_id=session_id_result, transcript=transcript)
|
||||
|
||||
def set_active_session(self, session_id: str) -> None:
|
||||
"""Set the active session.
|
||||
|
||||
Args:
|
||||
session_id: The ID of the session to set as active
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO active_session (id, session_id, updated_at)
|
||||
VALUES (1, ?, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_active_session(self) -> str | None:
|
||||
"""Get the ID of the active session.
|
||||
|
||||
Returns:
|
||||
The session ID if one is set, None otherwise
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT session_id FROM active_session WHERE id = 1
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def list_sessions(self) -> list[str]:
|
||||
"""List all session IDs.
|
||||
|
||||
Returns:
|
||||
A list of all session IDs in the database
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
cursor = conn.execute("""
|
||||
SELECT session_id FROM sessions ORDER BY updated_at DESC
|
||||
""")
|
||||
return [row[0] for row in cursor.fetchall()]
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Delete a session from the database.
|
||||
|
||||
Args:
|
||||
session_id: The ID of the session to delete
|
||||
"""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM sessions WHERE session_id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def clear_all(self) -> None:
|
||||
"""Clear all sessions from the database."""
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM sessions")
|
||||
conn.execute("DELETE FROM active_session")
|
||||
conn.commit()
|
||||
Reference in New Issue
Block a user