diff --git a/src/databao_cli/commands/build.py b/src/databao_cli/commands/build.py index bf4b00df..9cad887f 100644 --- a/src/databao_cli/commands/build.py +++ b/src/databao_cli/commands/build.py @@ -1,6 +1,7 @@ from databao_context_engine import BuildDatasourceResult, DatabaoContextDomainManager from databao_cli.project.layout import ProjectLayout +from databao_cli.ui.project_utils import write_build_sentinel def build_impl(project_layout: ProjectLayout, domain: str, should_index: bool) -> list[BuildDatasourceResult]: @@ -8,5 +9,6 @@ def build_impl(project_layout: ProjectLayout, domain: str, should_index: bool) - results: list[BuildDatasourceResult] = DatabaoContextDomainManager(domain_dir=dce_project_dir).build_context( datasource_ids=None, should_index=should_index ) + write_build_sentinel(dce_project_dir) return results diff --git a/src/databao_cli/ui/app.py b/src/databao_cli/ui/app.py index 6bcf87d8..5609205d 100644 --- a/src/databao_cli/ui/app.py +++ b/src/databao_cli/ui/app.py @@ -18,7 +18,12 @@ from databao_cli.ui.components.status import AppStatus, set_status, status_context from databao_cli.ui.models.chat_session import ChatSession from databao_cli.ui.models.settings import LLMSettings -from databao_cli.ui.project_utils import DatabaoProjectStatus, databao_project_status, has_build_output +from databao_cli.ui.project_utils import ( + DatabaoProjectStatus, + databao_project_status, + get_build_fingerprint, + has_build_output, +) from databao_cli.ui.services.storage import get_cache_dir logger = logging.getLogger(__name__) @@ -128,6 +133,7 @@ def _initialize_agent(project: ProjectLayout) -> Agent | None: _agent = create_agent(**kwargs) # type: ignore[arg-type] st.session_state.agent = _agent + st.session_state.build_fingerprint = get_build_fingerprint(project.root_domain_dir) return _agent @@ -185,6 +191,8 @@ def invalidate_agent(status_message: str = "Reloading project...") -> None: """ st.session_state.databao_project = None st.session_state.agent = None + st.session_state.pop("build_fingerprint", None) + st.session_state.pop("new_build_available", None) _clear_all_chat_threads() set_status(AppStatus.INITIALIZING, status_message) @@ -320,6 +328,11 @@ def init_session_state() -> None: if "build_error" not in st.session_state: st.session_state.build_error = None + if "build_fingerprint" not in st.session_state: + st.session_state.build_fingerprint = None + if "new_build_available" not in st.session_state: + st.session_state.new_build_available = False + def _create_new_chat() -> None: """Create a new chat and navigate to it.""" diff --git a/src/databao_cli/ui/components/chat.py b/src/databao_cli/ui/components/chat.py index fe9bf925..e0ebd33b 100644 --- a/src/databao_cli/ui/components/chat.py +++ b/src/databao_cli/ui/components/chat.py @@ -7,6 +7,7 @@ from databao_cli.ui.components.results import render_execution_result from databao_cli.ui.models.chat_session import ChatMessage +from databao_cli.ui.project_utils import get_build_fingerprint from databao_cli.ui.services import ( check_query_completion, get_query_phase, @@ -510,6 +511,47 @@ def _process_pending_overwrite(chat: "ChatSession") -> None: start_background_query(chat, pending) +@st.fragment(run_every=5.0) +def _new_build_polling_fragment() -> None: + """Poll for new build output every 5 seconds. + + Compares the current build fingerprint on disk with the one recorded + when the agent was loaded. Sets a session-state flag when a newer + build is detected so the banner can be shown. + """ + if st.session_state.get("new_build_available"): + return + + stored = st.session_state.get("build_fingerprint") + if stored is None: + return + + project = st.session_state.get("databao_project") + if project is None: + return + + current = get_build_fingerprint(project.root_domain_dir) + if current > stored: + st.session_state.new_build_available = True + st.rerun() + + +def _render_new_build_banner() -> None: + """Show a banner when a new build is available with a reload action.""" + if not st.session_state.get("new_build_available"): + return + + from databao_cli.ui.app import invalidate_agent + + col_msg, col_btn = st.columns([5, 1]) + with col_msg: + st.warning("A new build is available. Reload to use the latest context.") + with col_btn: + if st.button("Reload", key="new_build_reload_btn", type="primary"): + invalidate_agent("Reloading with new build...") + st.rerun() + + def render_chat_interface(chat: "ChatSession") -> None: """Render the complete chat interface. @@ -522,6 +564,9 @@ def render_chat_interface(chat: "ChatSession") -> None: """ _process_pending_overwrite(chat) + _render_new_build_banner() + _new_build_polling_fragment() + agent: Agent | None = st.session_state.get("agent") hide_build_context_hint: bool = bool(st.session_state.get("_hide_build_context_hint")) if agent is not None and not agent.domain.is_context_built() and not chat.messages and not hide_build_context_hint: diff --git a/src/databao_cli/ui/project_utils.py b/src/databao_cli/ui/project_utils.py index 47c3cabe..a18ed037 100644 --- a/src/databao_cli/ui/project_utils.py +++ b/src/databao_cli/ui/project_utils.py @@ -1,5 +1,6 @@ import logging from enum import Enum +from pathlib import Path from databao.agent.integrations.dce import DatabaoContextApi @@ -48,3 +49,33 @@ def has_build_output(project: ProjectLayout) -> bool: return len(dce_project.get_introspected_datasource_list()) > 0 except Exception: return False + + +BUILD_SENTINEL = ".build_complete" + + +def get_build_fingerprint(domain_dir: Path) -> float: + """Return a fingerprint representing the current build state. + + Reads the modification time of the sentinel file written after a + successful build. Returns 0.0 if the sentinel does not exist. + """ + sentinel = domain_dir / "output" / BUILD_SENTINEL + try: + return sentinel.stat().st_mtime if sentinel.is_file() else 0.0 + except OSError: + logger.debug("Failed to read build sentinel", exc_info=True) + return 0.0 + + +def write_build_sentinel(domain_dir: Path) -> None: + """Write (or touch) the build sentinel after a successful build. + + The sentinel is a zero-byte marker file whose mtime is compared by + ``get_build_fingerprint`` to detect new builds. + """ + output_dir = domain_dir / "output" + output_dir.mkdir(parents=True, exist_ok=True) + sentinel = output_dir / BUILD_SENTINEL + sentinel.touch() + logger.debug("Wrote build sentinel: %s", sentinel) diff --git a/src/databao_cli/ui/services/dce_operations.py b/src/databao_cli/ui/services/dce_operations.py index c62588f7..22095ecb 100644 --- a/src/databao_cli/ui/services/dce_operations.py +++ b/src/databao_cli/ui/services/dce_operations.py @@ -164,8 +164,12 @@ def verify_datasource_config(ds_type_str: str, ds_name: str, config: dict[str, A def build_context(project_dir: Path) -> list[BuildDatasourceResult]: """Build context for all datasources in the DCE project. This is a long-running operation.""" + from databao_cli.ui.project_utils import write_build_sentinel + manager = DatabaoContextDomainManager(domain_dir=project_dir) - return manager.build_context() + result = manager.build_context() + write_build_sentinel(project_dir) + return result def get_status_info(project_dir: Path) -> str: diff --git a/tests/test_build_fingerprint.py b/tests/test_build_fingerprint.py new file mode 100644 index 00000000..ff4efac3 --- /dev/null +++ b/tests/test_build_fingerprint.py @@ -0,0 +1,91 @@ +"""Tests for build fingerprint and sentinel (DBA-136).""" + +import os +from pathlib import Path + +import pytest + +from databao_cli.ui.project_utils import BUILD_SENTINEL, get_build_fingerprint, write_build_sentinel + + +@pytest.fixture +def domain_dir(tmp_path: Path) -> Path: + """Create a minimal domain directory structure.""" + domain = tmp_path / "domain" + domain.mkdir() + return domain + + +# -- get_build_fingerprint -- + + +def test_no_output_dir(domain_dir: Path) -> None: + """Returns 0.0 when the output directory does not exist.""" + assert get_build_fingerprint(domain_dir) == 0.0 + + +def test_output_dir_without_sentinel(domain_dir: Path) -> None: + """Returns 0.0 when output dir exists but sentinel is missing.""" + (domain_dir / "output").mkdir() + (domain_dir / "output" / "ds.yaml").write_text("tables: []") + assert get_build_fingerprint(domain_dir) == 0.0 + + +def test_fingerprint_from_sentinel(domain_dir: Path) -> None: + """Returns the mtime of the sentinel file.""" + output = domain_dir / "output" + output.mkdir() + sentinel = output / BUILD_SENTINEL + sentinel.touch() + expected = sentinel.stat().st_mtime + assert get_build_fingerprint(domain_dir) == expected + + +def test_fingerprint_changes_after_rebuild(domain_dir: Path) -> None: + """Fingerprint changes when sentinel is re-touched (simulating rebuild).""" + output = domain_dir / "output" + output.mkdir() + sentinel = output / BUILD_SENTINEL + sentinel.touch() + + fp1 = get_build_fingerprint(domain_dir) + + # Explicitly set a future mtime to avoid filesystem granularity issues. + future = fp1 + 10 + os.utime(sentinel, (future, future)) + + fp2 = get_build_fingerprint(domain_dir) + assert fp2 > fp1 + + +# -- write_build_sentinel -- + + +def test_write_sentinel_creates_file(domain_dir: Path) -> None: + """write_build_sentinel creates the sentinel (and output dir if needed).""" + write_build_sentinel(domain_dir) + sentinel = domain_dir / "output" / BUILD_SENTINEL + assert sentinel.is_file() + + +def test_write_sentinel_updates_mtime(domain_dir: Path) -> None: + """Calling write_build_sentinel again updates the sentinel mtime.""" + write_build_sentinel(domain_dir) + sentinel = domain_dir / "output" / BUILD_SENTINEL + fp1 = sentinel.stat().st_mtime + + # Set an old mtime, then re-touch via write_build_sentinel. + past = fp1 - 100 + os.utime(sentinel, (past, past)) + assert sentinel.stat().st_mtime < fp1 + + write_build_sentinel(domain_dir) + fp2 = sentinel.stat().st_mtime + assert fp2 >= fp1 + + +def test_fingerprint_round_trip(domain_dir: Path) -> None: + """get_build_fingerprint returns 0 before write, non-zero after.""" + assert get_build_fingerprint(domain_dir) == 0.0 + write_build_sentinel(domain_dir) + assert get_build_fingerprint(domain_dir) > 0.0