Skip to content
Merged
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
25 changes: 25 additions & 0 deletions local-docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,31 @@ services:
- temporal-network
stdin_open: true
tty: true
unoplat-code-confluence-query-engine:
# container_name: unoplat-code-confluence-query-engine
build:
context: ./unoplat-code-confluence-query-engine
dockerfile: Dockerfile
environment:
- NEO4J_HOST=neo4j
- NEO4J_PORT=7687
- NEO4J_USERNAME=neo4j
- NEO4J_PASSWORD=password
- DB_HOST=postgresql
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=code_confluence
ports:
- "8001:8000"
networks:
- temporal-network
depends_on:
postgresql:
condition: service_healthy
neo4j:
condition: service_healthy

unoplat-code-confluence-frontend:
# container_name: unoplat-code-confluence-frontend
depends_on:
Expand Down
22 changes: 22 additions & 0 deletions prod-docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,28 @@ services:
- temporal-network
stdin_open: true
tty: true
unoplat-code-confluence-query-engine:
image: ghcr.io/unoplat/unoplat-code-confluence-query-engine:0.1.0
environment:
- NEO4J_HOST=neo4j
- NEO4J_PORT=7687
- NEO4J_USERNAME=neo4j
- NEO4J_PASSWORD=password
- DB_HOST=postgresql
- DB_PORT=5432
- DB_USER=postgres
- DB_PASSWORD=postgres
- DB_NAME=code_confluence
ports:
- "8001:8000"
networks:
- temporal-network
depends_on:
postgresql:
condition: service_healthy
neo4j:
condition: service_healthy

unoplat-code-confluence-frontend:
image: ghcr.io/unoplat/unoplat-code-confluence-frontend:1.25.0
environment:
Expand Down
6 changes: 6 additions & 0 deletions unoplat-code-confluence-query-engine/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ tasks:
- task: start-dependencies
- task: run-dev

mock-sse:
desc: Run mock SSE server (replays docs/sse-agent-results.txt) on port 8002
dir: experiments
cmds:
- uv run fastapi dev mock_sse_server.py --port 8002

test:
desc: Run tests with coverage
dir: .
Expand Down
161 changes: 161 additions & 0 deletions unoplat-code-confluence-query-engine/experiments/mock_sse_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Mock SSE server that replays events from docs/sse-agent-results.txt.

This standalone FastAPI app exposes an endpoint compatible with the real
`/v1/codebase-agent-rules` route and streams Server-Sent Events in the exact
order captured in the reference log file. Useful for UI/dev testing without
running the full agent pipeline or external services.
"""

from __future__ import annotations

import asyncio
from pathlib import Path
from typing import AsyncGenerator, Dict, List, Optional

from fastapi import FastAPI, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from sse_starlette.sse import EventSourceResponse

APP_TITLE: str = "Mock Codebase Agent Rules SSE Server"
DEFAULT_DELAY_SECONDS: float = 0.05

# Resolve repository root and reference SSE log file
REPO_ROOT: Path = Path(__file__).resolve().parents[1]
SSE_LOG_PATH: Path = REPO_ROOT / "docs" / "sse-agent-results.txt"


Comment on lines +23 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Handle missing SSE log file in mock server

The mock SSE app loads REPO_ROOT / "docs" / "sse-agent-results.txt" during startup, but no such file exists anywhere in the repository (see find . -name "sse-agent-results.txt" returning nothing). Because parse_sse_log_file immediately raises FileNotFoundError when the file is missing, the FastAPI application never finishes starting and the new task mock-sse or Docker usage will crash before serving any events. Either ship the reference file with the repo or guard the startup logic so the server can run without it.

Useful? React with 👍 / 👎.

# In-memory cache of parsed SSE events
LOADED_EVENTS: List[Dict[str, str]] = []


def parse_sse_log_file(file_path: Path) -> List[Dict[str, str]]:
"""Parse a newline-delimited SSE capture file into event dicts.

The expected format mirrors what `curl -N` would show:
id: 1\n
event: repo:agent:event_type\n
data: {"json": "object"}\n
\n
- Blank line separates events
- Lines starting with "::" are treated as comments/keepalive pings and skipped

Returns a list of dicts each containing keys: "id", "event", "data".
Data is preserved as a raw JSON string to match the real endpoint behavior.
"""
events: List[Dict[str, str]] = []
current: Dict[str, str] = {}

try:
raw_text: str = file_path.read_text(encoding="utf-8")
except FileNotFoundError as exc:
logger.error("SSE log file not found at {}", file_path)
raise exc

for raw_line in raw_text.splitlines():
line: str = raw_line.strip()

if not line:
if "event" in current and "data" in current:
if "id" not in current:
current["id"] = str(len(events))
events.append(current)
current = {}
continue

if line.startswith("::"):
# Skip comment/ping lines
continue

if line.startswith("id:"):
current["id"] = line.partition("id:")[2].strip()
continue

if line.startswith("event:"):
current["event"] = line.partition("event:")[2].strip()
continue

if line.startswith("data:"):
# Preserve raw JSON string as-is
current["data"] = line.partition("data:")[2].strip()
continue

# Finalize trailing event if file doesn't end with a blank line
if current and "event" in current and "data" in current:
if "id" not in current:
current["id"] = str(len(events))
events.append(current)

return events


async def stream_mock_events(
request: Request, *, delay_seconds: float
) -> AsyncGenerator[Dict[str, str], None]:
"""Yield parsed SSE events to the client with an optional delay between them.

The yielded dicts follow sse-starlette's expected structure: keys "id",
"event", and "data". `data` remains a JSON string, matching the real
endpoint which serializes payloads prior to emission.
"""
for event in LOADED_EVENTS:
if await request.is_disconnected():
logger.info("Mock SSE client disconnected early")
break

yield event

if delay_seconds > 0:
await asyncio.sleep(delay_seconds)


app: FastAPI = FastAPI(title=APP_TITLE)

# Add CORS middleware to allow cross-origin requests (matches main.py configuration)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"], # Important for SSE headers
)


@app.on_event("startup")
async def on_startup() -> None:
"""Load and cache SSE events from the reference file on startup."""
global LOADED_EVENTS
LOADED_EVENTS = parse_sse_log_file(SSE_LOG_PATH)
logger.info("Loaded {} mock SSE events from {}", len(LOADED_EVENTS), SSE_LOG_PATH)


@app.get("/v1/codebase-agent-rules")
async def get_mock_codebase_agent_rules(
request: Request,
owner_name: str = Query(..., description="Repository owner name"),
repo_name: str = Query(..., description="Repository name"),
delay_seconds: Optional[float] = Query(
default=DEFAULT_DELAY_SECONDS,
ge=0.0,
le=2.0,
description="Artificial delay between events to simulate streaming",
),
) -> EventSourceResponse:
"""Stream mock SSE events in the same order as the captured session.

Query params are accepted for API compatibility, but do not affect the
mocked stream content.
"""
headers: Dict[str, str] = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return EventSourceResponse(
stream_mock_events(request, delay_seconds=delay_seconds or 0.0),
ping=10,
headers=headers,
)


Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@
router = APIRouter(prefix="/v1", tags=["codebase-rules"])


def build_repo_agent_event_name(
repository_qualified_name: str, agent_name: str, event_type: str
) -> str:
"""Construct a namespaced SSE event string as repo:agent:event_type.

Using repository-qualified name first ensures uniqueness across concurrent
SSE connections for different repositories.
"""
return f"{repository_qualified_name}:{agent_name}:{event_type}"


def log_sse_event(connection_id: str, event_count: int, event_type: str,
connection_start: float, last_event_time: float) -> float:
"""Log SSE event details for debugging timeout issues."""
Expand Down Expand Up @@ -215,8 +226,13 @@ async def generate_sse_events(
event_id = 0

# Send initial status event
initial_event_name = build_repo_agent_event_name(
ruleset_metadata.repository_qualified_name,
"aggregated_final_summary_agent",
"status",
)
initial_event = {
"event": "status",
"event": initial_event_name,
"data": json.dumps(
{
"message": "Initializing agent aggregation...",
Expand All @@ -226,7 +242,7 @@ async def generate_sse_events(
),
"id": str(event_id),
}
last_event_time = log_sse_event(connection_id, event_count, "status", connection_start, last_event_time)
last_event_time = log_sse_event(connection_id, event_count, initial_event_name, connection_start, last_event_time)
event_count += 1
yield initial_event
event_id += 1
Expand Down Expand Up @@ -391,12 +407,17 @@ async def generate_sse_events(
final_payload["codebases"][codebase_name] = final_model.model_dump_json(exclude_none=True) # type: ignore

# Emit final repository-level event
final_event_name = build_repo_agent_event_name(
ruleset_metadata.repository_qualified_name,
"aggregated_final_summary_agent",
"agent_md_output",
)
final_event = {
"event": "final:agent_md_output",
"event": final_event_name,
"data": json.dumps(final_payload, ensure_ascii=False),
"id": str(event_id),
}
last_event_time = log_sse_event(connection_id, event_count, "final:agent_md_output", connection_start, last_event_time)
last_event_time = log_sse_event(connection_id, event_count, final_event_name, connection_start, last_event_time)
event_count += 1
yield final_event

Expand All @@ -408,8 +429,13 @@ async def generate_sse_events(
duration = time.time() - connection_start
logger.info("SSE[{}] Connection cancelled after {:.2f}s, {} events sent",
connection_id, duration, event_count)
error_event_name = build_repo_agent_event_name(
ruleset_metadata.repository_qualified_name,
"aggregated_final_summary_agent",
"error",
)
yield {
"event": "error",
"event": error_event_name,
"data": json.dumps({"message": "Connection cancelled"}, ensure_ascii=False),
"id": str(event_id),
}
Expand All @@ -418,8 +444,13 @@ async def generate_sse_events(
duration = time.time() - connection_start
logger.error("SSE[{}] Connection error after {:.2f}s: {}",
connection_id, duration, e)
error_event_name = build_repo_agent_event_name(
ruleset_metadata.repository_qualified_name,
"aggregated_final_summary_agent",
"error",
)
yield {
"event": "error",
"event": error_event_name,
"data": json.dumps({"error": str(e)}, ensure_ascii=False),
"id": str(event_id),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ class AgentMdOutput(BaseModel):
..., description="Critical business logic domains"
)
#TODO: remove optional when ready
app_interaces: Optional[Interfaces] = Field(
app_interfaces: Optional[Interfaces] = Field(
default=None, description="Inbound or/and outboundInteraces used in the codebase"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

if TYPE_CHECKING:
from fastapi import FastAPI

from unoplat_code_confluence_query_engine.config.settings import EnvironmentSettings

# Global state for tracking configuration changes and model instances
Expand Down Expand Up @@ -173,7 +174,9 @@ async def get_current_model(settings: Optional["EnvironmentSettings"] = None) ->

# Use provided settings or fall back to creating new instance
if settings is None:
from unoplat_code_confluence_query_engine.config.settings import EnvironmentSettings
from unoplat_code_confluence_query_engine.config.settings import (
EnvironmentSettings,
)
settings = EnvironmentSettings()

_current_model, _current_model_settings = await _model_factory.build(config, settings)
Expand Down
2 changes: 1 addition & 1 deletion unoplat-code-confluence-query-engine/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions yak/yaak.rq_ZCT8QECJLF.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
type: http_request
model: http_request
id: rq_ZCT8QECJLF
createdAt: 2025-09-15T11:23:47.226515
updatedAt: 2025-09-15T11:24:59.927451
workspaceId: wk_KULjx4bEa8
folderId: fl_zukJtBcFCC
authentication: {}
authenticationType: null
body: {}
bodyType: null
description: ''
headers: []
method: GET
name: mock-test-agent-pc-dw-bl-xai-grok-code-fast-1-or-all-codebases
sortPriority: 4000.001
url: http://localhost:8002/v1/codebase-agent-rules
urlParameters:
- enabled: true
name: owner_name
value: unoplat
id: XVEctDb8wq
- enabled: true
name: repo_name
value: unoplat-code-confluence
id: 3apksU6Xei
- enabled: true
name: ''
value: ''
id: zfTZmKzX25
Loading