Skip to content

Commit 75d6e52

Browse files
enyr-srlEnyr
andauthored
fix(chat): route /api/repos/{repo_id}/chat/messages by workspace_sessions (#146)
In workspace mode every non-primary repo's chat returns ``404 Repository <id> not found`` even though the same id is listed in both ``GET /api/repos`` and ``GET /api/workspace.repos[].repo_id``. The chat router was the only ``/api/repos/{repo_id}/...`` endpoint not honouring ``app.state.workspace_sessions``. The conversation endpoints (``GET /chat/conversations``, etc.) already work correctly because they use ``Depends(get_db_session)``, which routes by ``repo_id`` automatically. ``chat_messages`` couldn't use that dep — it needs the *factory* (not a single session) so it can open multiple sessions over the SSE lifetime — so it was hand-wiring ``request.app.state.session_factory`` and missing the workspace branch. Fix: * Extract the factory-resolution logic from ``get_db_session`` into two reusable helpers in ``deps.py``: - ``resolve_session_factory(app_state, repo_id)`` — pure function, same logic as the existing private helper in ``routers/repos.py``. - ``resolve_request_session_factory(request)`` — request-scoped sibling that reads ``repo_id`` from path/query params (mirrors what ``get_db_session`` encodes). * ``chat_messages`` calls ``resolve_request_session_factory(request)`` instead of ``request.app.state.session_factory`` directly. * ``routers/repos.py::_resolve_repo_session_factory`` is now a one-line alias around the shared helper, removing the duplicate logic. Test: * New regression test ``tests/unit/server/test_chat_workspace.py``: - ``test_chat_messages_resolves_non_primary_repo_in_workspace_mode`` — POST to a non-primary ``repo_id`` no longer returns 404. Verified to fail on the unpatched source with the exact ``Repository <id> not found`` message. - ``test_chat_messages_still_finds_primary_repo`` — the single-factory fallback still works when ``workspace_sessions`` is empty (single-repo mode and the primary repo of a workspace). * All 126 existing ``tests/unit/server/`` tests pass with the patch. Co-authored-by: Enyr <info@enyr.eu>
1 parent a6527cb commit 75d6e52

4 files changed

Lines changed: 203 additions & 21 deletions

File tree

packages/server/src/repowise/server/deps.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,42 @@
3636
)
3737

3838

39+
def resolve_session_factory(app_state, repo_id: str | None):
40+
"""Pick the session factory whose database contains ``repo_id``.
41+
42+
In workspace mode each repo has its own ``wiki.db`` registered under
43+
``app_state.workspace_sessions[repo_id]``. The primary
44+
``app_state.session_factory`` does NOT see those rows. When ``repo_id``
45+
is ``None`` or unknown, falls back to the primary factory (single-repo
46+
mode, or the primary repo of a workspace).
47+
"""
48+
if repo_id:
49+
ws_sessions = getattr(app_state, "workspace_sessions", None)
50+
if ws_sessions and repo_id in ws_sessions:
51+
return ws_sessions[repo_id]
52+
return app_state.session_factory
53+
54+
55+
def resolve_request_session_factory(request: Request):
56+
"""Return the session factory for the active route's ``repo_id``.
57+
58+
Reads ``repo_id`` from the path (e.g. ``/api/repos/{repo_id}/...``)
59+
or query string (e.g. ``/api/pages?repo_id=xxx``), then delegates to
60+
:func:`resolve_session_factory`. Used by streaming endpoints that
61+
can't take a single ``Depends(get_db_session)`` because they need to
62+
open multiple sessions over the request lifetime.
63+
"""
64+
repo_id = request.path_params.get("repo_id") or request.query_params.get("repo_id")
65+
return resolve_session_factory(request.app.state, repo_id)
66+
67+
3968
async def get_db_session(request: Request) -> AsyncGenerator[AsyncSession, None]:
4069
"""Yield an async DB session with auto-commit on success, rollback on error.
4170
4271
In workspace mode, routes to the correct repo's DB based on the
4372
``repo_id`` path parameter when a matching session factory exists.
4473
"""
45-
factory = request.app.state.session_factory
46-
47-
# In workspace mode, check if the request targets a specific repo DB
48-
# Check both path params (e.g. /api/repos/{repo_id}/stats) and
49-
# query params (e.g. /api/pages?repo_id=xxx) for the repo_id
50-
repo_id = request.path_params.get("repo_id") or request.query_params.get("repo_id")
51-
if repo_id:
52-
ws_sessions = getattr(request.app.state, "workspace_sessions", None)
53-
if ws_sessions and repo_id in ws_sessions:
54-
factory = ws_sessions[repo_id]
55-
74+
factory = resolve_request_session_factory(request)
5675
async with get_session(factory) as session:
5776
yield session
5877

packages/server/src/repowise/server/routers/chat.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
get_artifact_type,
1818
get_tool_schemas_for_llm,
1919
)
20-
from repowise.server.deps import get_db_session, verify_api_key
20+
from repowise.server.deps import (
21+
get_db_session,
22+
resolve_request_session_factory,
23+
verify_api_key,
24+
)
2125
from repowise.server.provider_config import get_chat_provider_instance, set_active_provider
2226
from repowise.server.schemas import (
2327
ChatMessageResponse,
@@ -70,7 +74,12 @@ async def _get_repo_info(factory: Any, repo_id: str) -> tuple[str, str]:
7074
@router.post("/api/repos/{repo_id}/chat/messages")
7175
async def chat_messages(repo_id: str, body: ChatRequest, request: Request):
7276
"""Stream an agentic chat response via SSE."""
73-
factory = request.app.state.session_factory
77+
# In workspace mode each repo has its own ``wiki.db``; the primary
78+
# ``app.state.session_factory`` does NOT contain non-primary repos'
79+
# rows, so resolving by ``repo_id`` is required for the
80+
# ``_get_repo_info`` lookup (and every subsequent ``get_session``
81+
# call inside ``event_stream``) to land in the right database.
82+
factory = resolve_request_session_factory(request)
7483

7584
# Resolve repo
7685
repo_name, repo_path = await _get_repo_info(factory, repo_id)

packages/server/src/repowise/server/routers/repos.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -271,16 +271,15 @@ async def full_resync(
271271

272272

273273
def _resolve_repo_session_factory(app_state, repo_id: str):
274-
"""Return the session_factory whose database contains this repo's row.
274+
"""Backward-compatible alias for :func:`deps.resolve_session_factory`.
275275
276-
In workspace mode each repo has its own ``wiki.db`` registered under
277-
``app_state.workspace_sessions[repo_id]``. The primary session_factory
278-
(``app_state.session_factory``) does NOT see those rows.
276+
Kept to avoid churn at call sites; new code should call
277+
``resolve_session_factory`` (or its request-scoped sibling
278+
``resolve_request_session_factory``) directly.
279279
"""
280-
ws_sessions = getattr(app_state, "workspace_sessions", None)
281-
if ws_sessions and repo_id in ws_sessions:
282-
return ws_sessions[repo_id]
283-
return app_state.session_factory
280+
from repowise.server.deps import resolve_session_factory # noqa: PLC0415
281+
282+
return resolve_session_factory(app_state, repo_id)
284283

285284

286285
def _launch_job_task(request: Request, job_id: str, repo_id: str) -> None:
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Regression test: chat endpoints honour ``workspace_sessions`` routing.
2+
3+
Bug (pre-fix): in workspace mode each repo has its own ``wiki.db``
4+
registered under ``app.state.workspace_sessions[repo_id]``. The
5+
chat endpoint resolved ``repo_id`` against the primary's session
6+
factory (``app.state.session_factory``), which doesn't contain the
7+
non-primary repos' rows. Result: every chat request to a
8+
non-primary repo 404'd with ``Repository {repo_id} not found``,
9+
even though the same id is listed in ``GET /api/repos`` and
10+
``GET /api/workspace.repos[].repo_id``.
11+
12+
Fix: ``chat_messages`` now uses
13+
:func:`repowise.server.deps.resolve_request_session_factory`,
14+
which mirrors the routing logic that ``get_db_session`` (used by
15+
the conversation endpoints) already encoded.
16+
17+
The test below builds a minimal FastAPI app with one primary repo
18+
in the global session factory and a non-primary repo in
19+
``workspace_sessions``, and asserts that POST /chat/messages on
20+
the *non-primary* id passes the lookup (i.e. does not 404 on the
21+
``Repository ... not found`` branch).
22+
"""
23+
24+
from __future__ import annotations
25+
26+
from contextlib import asynccontextmanager
27+
from datetime import UTC, datetime
28+
from unittest.mock import AsyncMock, patch
29+
30+
import pytest
31+
from httpx import ASGITransport, AsyncClient
32+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
33+
from sqlalchemy.pool import StaticPool
34+
35+
from fastapi import FastAPI
36+
from fastapi.responses import JSONResponse
37+
from repowise.core.persistence.database import init_db
38+
from repowise.core.persistence.models import Repository
39+
from repowise.server.routers import chat
40+
41+
42+
_NOW = datetime(2026, 4, 12, 10, 0, 0, tzinfo=UTC)
43+
44+
45+
async def _make_factory_with_repo(*, repo_id: str, name: str):
46+
"""Build an in-memory async session factory containing one repo row."""
47+
engine = create_async_engine(
48+
"sqlite+aiosqlite:///:memory:",
49+
connect_args={"check_same_thread": False},
50+
poolclass=StaticPool,
51+
)
52+
await init_db(engine)
53+
factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
54+
async with factory() as session:
55+
session.add(
56+
Repository(
57+
id=repo_id,
58+
name=name,
59+
url=f"https://example.com/{name}",
60+
local_path=f"/workspace/{name}",
61+
default_branch="main",
62+
settings_json="{}",
63+
created_at=_NOW,
64+
updated_at=_NOW,
65+
)
66+
)
67+
await session.commit()
68+
return factory
69+
70+
71+
def _build_app(*, primary_factory, workspace_sessions: dict) -> FastAPI:
72+
@asynccontextmanager
73+
async def noop_lifespan(app: FastAPI):
74+
yield
75+
76+
app = FastAPI(title="chat-workspace-test", lifespan=noop_lifespan)
77+
78+
@app.exception_handler(LookupError)
79+
async def _lookup(_request, exc):
80+
return JSONResponse(status_code=404, content={"detail": str(exc)})
81+
82+
app.state.session_factory = primary_factory
83+
app.state.workspace_sessions = workspace_sessions
84+
app.include_router(chat.router)
85+
return app
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_chat_messages_resolves_non_primary_repo_in_workspace_mode():
90+
"""Pin the workspace-routing fix: the non-primary id must NOT 404."""
91+
primary = await _make_factory_with_repo(repo_id="primary-id", name="primary")
92+
non_primary = await _make_factory_with_repo(
93+
repo_id="non-primary-id", name="non-primary"
94+
)
95+
96+
app = _build_app(
97+
primary_factory=primary,
98+
workspace_sessions={"non-primary-id": non_primary},
99+
)
100+
101+
# Stop after the chat handler has resolved the repo by replacing
102+
# the chat-provider factory; if the lookup 404s, this never runs.
103+
fake_provider = AsyncMock()
104+
fake_provider.provider_name = "openai"
105+
106+
with (
107+
patch(
108+
"repowise.server.routers.chat.get_chat_provider_instance",
109+
return_value=fake_provider,
110+
),
111+
):
112+
transport = ASGITransport(app=app)
113+
async with AsyncClient(
114+
transport=transport, base_url="http://testserver"
115+
) as client:
116+
response = await client.post(
117+
"/api/repos/non-primary-id/chat/messages",
118+
json={"message": "hi"},
119+
)
120+
121+
# Pre-fix this returned 404 "Repository non-primary-id not found".
122+
# Post-fix the lookup succeeds — the 422 here is the next branch
123+
# ("Provider does not support streaming chat") because our fake
124+
# provider isn't a ChatProvider. Either 200 (full happy path) or
125+
# 422 (provider check) proves the workspace routing worked.
126+
assert response.status_code != 404, response.text
127+
128+
129+
@pytest.mark.asyncio
130+
async def test_chat_messages_still_finds_primary_repo():
131+
"""Single-factory fallback: when ``repo_id`` isn't in
132+
``workspace_sessions``, the resolver falls back to
133+
``app.state.session_factory`` — covers single-repo mode AND the
134+
primary repo of a workspace, both of which keep the row in the
135+
global factory."""
136+
primary = await _make_factory_with_repo(repo_id="primary-id", name="primary")
137+
app = _build_app(primary_factory=primary, workspace_sessions={})
138+
139+
fake_provider = AsyncMock()
140+
fake_provider.provider_name = "openai"
141+
142+
with patch(
143+
"repowise.server.routers.chat.get_chat_provider_instance",
144+
return_value=fake_provider,
145+
):
146+
transport = ASGITransport(app=app)
147+
async with AsyncClient(
148+
transport=transport, base_url="http://testserver"
149+
) as client:
150+
response = await client.post(
151+
"/api/repos/primary-id/chat/messages",
152+
json={"message": "hi"},
153+
)
154+
155+
assert response.status_code != 404, response.text

0 commit comments

Comments
 (0)