diff --git a/src/databao_cli/commands/build.py b/src/databao_cli/commands/build.py index 28499c9b..7d857a3a 100644 --- a/src/databao_cli/commands/build.py +++ b/src/databao_cli/commands/build.py @@ -28,7 +28,9 @@ def build(ctx: click.Context, domain: str, should_index: bool) -> None: """ from databao_cli.features.build import build_impl from databao_cli.shared.cli_utils import get_project_or_raise + from databao_cli.shared.project.layout import write_build_sentinel project_layout = get_project_or_raise(ctx.obj["project_dir"]) results = build_impl(project_layout, domain, should_index) + write_build_sentinel(project_layout.domains_dir / domain) click.echo(f"Build complete. Processed {len(results)} datasources.") diff --git a/src/databao_cli/features/ui/app.py b/src/databao_cli/features/ui/app.py index d133d17d..08bebd7f 100644 --- a/src/databao_cli/features/ui/app.py +++ b/src/databao_cli/features/ui/app.py @@ -17,7 +17,12 @@ from databao_cli.features.ui.components.status import AppStatus, set_status, status_context from databao_cli.features.ui.models.chat_session import ChatSession from databao_cli.features.ui.models.settings import LLMSettings -from databao_cli.features.ui.project_utils import DatabaoProjectStatus, databao_project_status, has_build_output +from databao_cli.features.ui.project_utils import ( + DatabaoProjectStatus, + databao_project_status, + get_build_fingerprint, + has_build_output, +) from databao_cli.features.ui.services.storage import get_cache_dir from databao_cli.shared.project.layout import ProjectLayout, find_project @@ -128,6 +133,8 @@ 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) + st.session_state.new_build_available = False return _agent @@ -311,6 +318,11 @@ def init_session_state() -> None: if "title_futures" not in st.session_state: st.session_state.title_futures = {} + if "build_fingerprint" not in st.session_state: + st.session_state.build_fingerprint = 0.0 + if "new_build_available" not in st.session_state: + st.session_state.new_build_available = False + if "build_status" not in st.session_state: st.session_state.build_status = "not_started" if "build_future" not in st.session_state: diff --git a/src/databao_cli/features/ui/components/chat.py b/src/databao_cli/features/ui/components/chat.py index 6e5aa192..bda73138 100644 --- a/src/databao_cli/features/ui/components/chat.py +++ b/src/databao_cli/features/ui/components/chat.py @@ -7,6 +7,7 @@ from databao_cli.features.ui.components.results import render_execution_result from databao_cli.features.ui.models.chat_session import ChatMessage +from databao_cli.features.ui.project_utils import get_build_fingerprint from databao_cli.features.ui.services import ( check_query_completion, get_query_phase, @@ -510,6 +511,57 @@ def _process_pending_overwrite(chat: "ChatSession") -> None: start_background_query(chat, pending) +@st.fragment(run_every=5.0) +def _new_build_notification_fragment() -> None: + """Poll for build changes and show an inline banner when detected. + + Runs every 5 seconds. When the on-disk build fingerprint is newer than + the one captured at agent init, it shows a toast (once) and renders an + warning banner with a Reload button. The banner and polling live + in the same fragment, so no full-page ``st.rerun()`` is needed for the + banner to appear. + """ + project = st.session_state.get("databao_project") + if project is None: + return + + stored = st.session_state.get("build_fingerprint", 0.0) + + # Detect change + if not st.session_state.get("new_build_available"): + current = get_build_fingerprint(project) + if stored == 0.0: + # No build existed at init. Silently adopt the first build + # so subsequent builds are detected. + if current > 0.0: + st.session_state.build_fingerprint = current + return + if current > stored: + st.session_state.new_build_available = True + st.toast( + "A new build is available. Reload to use the latest context.", + icon="\u26a0\ufe0f", + ) + + # Render banner + if not st.session_state.get("new_build_available"): + return + + from databao_cli.features.ui.app import invalidate_agent + from databao_cli.features.ui.suggestions import reset_suggestions_state + + col1, col2 = st.columns([10, 2], vertical_alignment="center") + with col1: + st.warning("A new build is available. Reload to use the latest context.") + with col2: + if st.button("Reload", key="new_build_reload_btn", type="primary", use_container_width=True): + st.session_state.new_build_available = False + st.session_state.build_fingerprint = 0.0 + invalidate_agent("Reloading project...") + reset_suggestions_state() + st.rerun() + + def render_chat_interface(chat: "ChatSession") -> None: """Render the complete chat interface. @@ -554,6 +606,7 @@ def render_chat_interface(chat: "ChatSession") -> None: _query_polling_fragment() st.markdown("
", unsafe_allow_html=True) + _new_build_notification_fragment() _render_chat_input_bar(chat, query_running) if "pending_plot_message_index" in st.session_state: diff --git a/src/databao_cli/features/ui/project_utils.py b/src/databao_cli/features/ui/project_utils.py index f16fa067..59887f27 100644 --- a/src/databao_cli/features/ui/project_utils.py +++ b/src/databao_cli/features/ui/project_utils.py @@ -3,7 +3,7 @@ from databao.agent.integrations.dce import DatabaoContextApi -from databao_cli.shared.project.layout import ProjectLayout +from databao_cli.shared.project.layout import BUILD_SENTINEL, ProjectLayout logger = logging.getLogger(__name__) @@ -37,6 +37,22 @@ def databao_project_status(project: ProjectLayout) -> DatabaoProjectStatus: return DatabaoProjectStatus.VALID +def get_build_fingerprint(project: ProjectLayout) -> float: + """Return a fingerprint representing the last completed build. + + Reads the modification time of the ``.build_complete`` sentinel file + written by the build command after a successful build. This avoids + false positives from files being written *during* a build. + + Returns 0.0 if the sentinel does not exist. + """ + sentinel = project.root_domain_dir / BUILD_SENTINEL + try: + return sentinel.stat().st_mtime + except OSError: + return 0.0 + + def has_build_output(project: ProjectLayout) -> bool: """Check whether the project has any build output (introspected datasources). diff --git a/src/databao_cli/features/ui/services/dce_operations.py b/src/databao_cli/features/ui/services/dce_operations.py index 17a436b2..0293bbc7 100644 --- a/src/databao_cli/features/ui/services/dce_operations.py +++ b/src/databao_cli/features/ui/services/dce_operations.py @@ -24,7 +24,7 @@ from databao_context_engine.pluginlib.plugin_utils import check_connection_for_datasource from databao_cli.features.init.service import init_impl -from databao_cli.shared.project.layout import ProjectLayout +from databao_cli.shared.project.layout import ProjectLayout, write_build_sentinel logger = logging.getLogger(__name__) @@ -165,7 +165,9 @@ 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.""" manager = DatabaoContextDomainManager(domain_dir=project_dir) - return manager.build_context() + results = manager.build_context() + write_build_sentinel(project_dir) + return results def get_status_info(project_dir: Path) -> str: diff --git a/src/databao_cli/shared/project/layout.py b/src/databao_cli/shared/project/layout.py index 769e1503..f409f29e 100644 --- a/src/databao_cli/shared/project/layout.py +++ b/src/databao_cli/shared/project/layout.py @@ -1,8 +1,12 @@ +import logging import os from dataclasses import dataclass from pathlib import Path +logger = logging.getLogger(__name__) + ROOT_DOMAIN = "root" +BUILD_SENTINEL = ".build_complete" def get_databao_project_dir(project_dir: Path) -> Path: @@ -49,6 +53,13 @@ def get_domain_names(self) -> list[str]: return [domain for domain in os.listdir(domains_dir) if (domains_dir / domain).is_dir()] +def write_build_sentinel(domain_dir: Path) -> None: + """Write a sentinel file to signal that a build has completed.""" + sentinel = domain_dir / BUILD_SENTINEL + sentinel.write_text("") + logger.debug("Wrote build sentinel: %s", sentinel) + + def find_project(initial_dir: Path) -> ProjectLayout | None: dirs_to_check = [initial_dir, *list(initial_dir.parents)] for project_dir_candidate in dirs_to_check: diff --git a/tests/test_build_fingerprint.py b/tests/test_build_fingerprint.py new file mode 100644 index 00000000..f753a8a8 --- /dev/null +++ b/tests/test_build_fingerprint.py @@ -0,0 +1,62 @@ +"""Tests for build fingerprint detection and new-build notification logic.""" + +import os +from pathlib import Path + +import pytest + +from databao_cli.features.ui.project_utils import get_build_fingerprint +from databao_cli.shared.project.layout import BUILD_SENTINEL, ProjectLayout + + +@pytest.fixture +def project_with_sentinel(tmp_path: Path) -> ProjectLayout: + """Create a minimal project layout with a build sentinel file.""" + domain_dir = tmp_path / "databao" / "domains" / "root" + domain_dir.mkdir(parents=True) + (domain_dir / BUILD_SENTINEL).write_text("") + return ProjectLayout(tmp_path) + + +def test_fingerprint_returns_zero_without_sentinel(tmp_path: Path) -> None: + """Fingerprint is 0.0 when the sentinel file does not exist.""" + domain_dir = tmp_path / "databao" / "domains" / "root" + domain_dir.mkdir(parents=True) + project = ProjectLayout(tmp_path) + assert get_build_fingerprint(project) == 0.0 + + +def test_fingerprint_returns_zero_for_missing_domain(tmp_path: Path) -> None: + """Fingerprint is 0.0 when the domain directory does not exist.""" + project = ProjectLayout(tmp_path) + assert get_build_fingerprint(project) == 0.0 + + +def test_fingerprint_returns_nonzero_with_sentinel( + project_with_sentinel: ProjectLayout, +) -> None: + """Fingerprint is positive when sentinel file exists.""" + fp = get_build_fingerprint(project_with_sentinel) + assert fp > 0.0 + + +def test_fingerprint_changes_after_new_build( + project_with_sentinel: ProjectLayout, +) -> None: + """Fingerprint changes when sentinel is rewritten (simulating a new build).""" + fp_before = get_build_fingerprint(project_with_sentinel) + + sentinel = project_with_sentinel.root_domain_dir / BUILD_SENTINEL + os.utime(sentinel, (fp_before + 10, fp_before + 10)) + + fp_after = get_build_fingerprint(project_with_sentinel) + assert fp_after > fp_before + + +def test_fingerprint_stable_without_changes( + project_with_sentinel: ProjectLayout, +) -> None: + """Fingerprint is stable when sentinel is not rewritten.""" + fp1 = get_build_fingerprint(project_with_sentinel) + fp2 = get_build_fingerprint(project_with_sentinel) + assert fp1 == fp2