Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/databao_cli/commands/build.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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]:
dce_project_dir = project_layout.domains_dir / domain
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
15 changes: 14 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,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

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

Expand Down Expand Up @@ -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."""
Expand Down
45 changes: 45 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,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.

Expand All @@ -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:
Expand Down
31 changes: 31 additions & 0 deletions src/databao_cli/ui/project_utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from enum import Enum
from pathlib import Path

from databao.agent.integrations.dce import DatabaoContextApi

Expand Down Expand Up @@ -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)
6 changes: 5 additions & 1 deletion src/databao_cli/ui/services/dce_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
91 changes: 91 additions & 0 deletions tests/test_build_fingerprint.py
Original file line number Diff line number Diff line change
@@ -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
Loading