Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/databao_cli/commands/build.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from databao_context_engine import BuildDatasourceResult, DatabaoContextDomainManager

from databao_cli.project.layout import ProjectLayout
from databao_cli.project.layout import ProjectLayout, write_build_sentinel


def build_impl(project_layout: ProjectLayout, domain: str, should_index: bool) -> list[BuildDatasourceResult]:
Expand All @@ -9,4 +9,5 @@ def build_impl(project_layout: ProjectLayout, domain: str, should_index: bool) -
datasource_ids=None, should_index=should_index
)

write_build_sentinel(dce_project_dir)
return results
11 changes: 11 additions & 0 deletions src/databao_cli/project/layout.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 13 additions & 1 deletion src/databao_cli/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions src/databao_cli/ui/components/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.ui.app import invalidate_agent
from databao_cli.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.

Expand Down Expand Up @@ -554,6 +606,7 @@ def render_chat_interface(chat: "ChatSession") -> None:
_query_polling_fragment()

st.markdown("<div style='height: 2em'></div>", unsafe_allow_html=True)
_new_build_notification_fragment()
_render_chat_input_bar(chat, query_running)

if "pending_plot_message_index" in st.session_state:
Expand Down
18 changes: 17 additions & 1 deletion src/databao_cli/ui/project_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from databao.agent.integrations.dce import DatabaoContextApi

from databao_cli.project.layout import ProjectLayout
from databao_cli.project.layout import BUILD_SENTINEL, ProjectLayout

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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).

Expand Down
6 changes: 4 additions & 2 deletions src/databao_cli/ui/services/dce_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from databao_context_engine.pluginlib.plugin_utils import check_connection_for_datasource

from databao_cli.commands.init import init_impl
from databao_cli.project.layout import ProjectLayout
from databao_cli.project.layout import ProjectLayout, write_build_sentinel

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions tests/test_build_fingerprint.py
Original file line number Diff line number Diff line change
@@ -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.project.layout import BUILD_SENTINEL, ProjectLayout
from databao_cli.ui.project_utils import get_build_fingerprint


@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
Loading