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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dist/
build/
.pytest_cache/
.mypy_cache/
.ruff_cache
.coverage
htmlcov/

Expand Down
2 changes: 1 addition & 1 deletion backend/app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def __init__(self, job_id: str, proposal_index: int) -> None:
super().__init__(f"Proposal not found: job={job_id}, index={proposal_index}")


class K8sServiceError(Exception):
class K8sClientError(Exception):
"""Raised when a Kubernetes operation fails."""


Expand Down
4 changes: 2 additions & 2 deletions backend/app/core/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
ArtifactNotFoundError,
InvalidJobStateError,
JobNotFoundError,
K8sServiceError,
K8sClientError,
ProposalNotFoundError,
)

Expand All @@ -32,7 +32,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
return JSONResponse(status_code=404, content={"detail": str(e)})
except InvalidJobStateError as e:
return JSONResponse(status_code=400, content={"detail": str(e)})
except K8sServiceError as e:
except K8sClientError as e:
logger.error("K8s service error: %s", e)
return JSONResponse(
status_code=503,
Expand Down
12 changes: 6 additions & 6 deletions backend/app/di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

from sqlalchemy.orm import Session

from app.infra.log_stream_client import LogStreamClient
from app.repository.database import SessionLocal
from app.repository.setting_repository import SettingRepository
from app.service.log_stream_service import LogStreamService


class DIContainer:
"""依存性注入コンテナ"""

_log_stream_service: LogStreamService | None = None
_log_stream_client: LogStreamClient | None = None

@staticmethod
def get_db() -> Generator[Session]:
Expand All @@ -26,7 +26,7 @@ def get_setting_repository(db: Session) -> SettingRepository:
return SettingRepository(db)

@classmethod
def get_log_stream_service(cls) -> LogStreamService:
if cls._log_stream_service is None:
cls._log_stream_service = LogStreamService()
return cls._log_stream_service
def get_log_stream_client(cls) -> LogStreamClient:
if cls._log_stream_client is None:
cls._log_stream_client = LogStreamClient()
return cls._log_stream_client
12 changes: 6 additions & 6 deletions backend/app/di/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from sqlalchemy.orm import Session

from app.di.container import DIContainer
from app.infra.log_stream_client import LogStreamClient
from app.infra.s3_client import S3Client
from app.repository.setting_repository import SettingRepository
from app.service.log_stream_service import LogStreamService
from app.service.s3_service import S3Service


def get_db() -> Generator[Session]:
Expand All @@ -18,9 +18,9 @@ def get_setting_repository(db: Session = Depends(get_db)) -> SettingRepository:
return DIContainer.get_setting_repository(db)


def get_s3_service() -> S3Service:
return S3Service()
def get_s3_client() -> S3Client:
return S3Client()


def get_log_stream_service() -> LogStreamService:
return DIContainer.get_log_stream_service()
def get_log_stream_client() -> LogStreamClient:
return DIContainer.get_log_stream_client()
3 changes: 3 additions & 0 deletions backend/app/infra/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .k8s_client import K8sClient

__all__ = ["K8sClient"]
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
logger = logging.getLogger(__name__)


class K8sService:
"""Kubernetes Job management service."""
class K8sClient:
"""Kubernetes Job management client."""

def __init__(self) -> None:
settings = get_settings()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from collections.abc import AsyncIterator
from dataclasses import dataclass, field

from app.service.k8s_service import K8sService
from app.infra.k8s_client import K8sClient

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -38,7 +38,7 @@ def parse_log_line(line: str) -> dict | None:
return None


class LogStreamService:
class LogStreamClient:
"""Manages log streaming for sessions. Singleton per backend process."""

def __init__(self) -> None:
Expand Down Expand Up @@ -83,7 +83,7 @@ async def stream_session_logs(
since_seconds: If set, only stream logs newer than this many seconds.
Used on reconnect to avoid replaying old logs.
"""
k8s = K8sService()
k8s = K8sClient()
queue: asyncio.Queue[dict | None] = asyncio.Queue()
tracked_jobs: set[str] = set()
tasks: list[asyncio.Task[None]] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
logger = logging.getLogger(__name__)


class S3Service:
class S3Client:
"""S3-compatible artifact storage (works with AWS S3 and MinIO)."""

def __init__(self) -> None:
Expand Down
10 changes: 10 additions & 0 deletions backend/app/repository/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
from .database import SessionLocal
from .iteration_repository import IterationRepository
from .proposal_repository import ProposalRepository
from .protocols import (
IterationRepositoryProtocol,
ProposalRepositoryProtocol,
SessionRepositoryProtocol,
SettingRepositoryProtocol,
)
from .session_repository import SessionRepository
from .setting_repository import SettingRepository

Expand All @@ -24,4 +30,8 @@
"IterationRepository",
"ProposalRepository",
"SettingRepository",
"SessionRepositoryProtocol",
"IterationRepositoryProtocol",
"ProposalRepositoryProtocol",
"SettingRepositoryProtocol",
]
9 changes: 9 additions & 0 deletions backend/app/repository/mock/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .mock_iteration_repository import MockIterationRepository
from .mock_proposal_repository import MockProposalRepository
from .mock_session_repository import MockSessionRepository

__all__ = [
"MockSessionRepository",
"MockIterationRepository",
"MockProposalRepository",
]
56 changes: 56 additions & 0 deletions backend/app/repository/mock/mock_iteration_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from uuid import UUID

from app.model.session import Iteration, IterationStatus


class MockIterationRepository:
def __init__(self) -> None:
self._store: dict[UUID, Iteration] = {}

def create(self, iteration: Iteration) -> Iteration:
self._store[iteration.id] = iteration
return iteration
Comment on lines +10 to +12

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

MockIterationRepository.create() assumes iteration.id is already set. In code paths that construct Iteration(...) without flushing (e.g., usecase unit tests), the SQLAlchemy default UUID may not be assigned yet. To emulate the DB-backed repository behavior, generate and set an ID in the mock when it’s missing before storing the object.

Copilot uses AI. Check for mistakes.

def get_by_id(self, iteration_id: UUID) -> Iteration | None:
return self._store.get(iteration_id)

def get_by_session_and_index(self, session_id: UUID, iteration_index: int) -> Iteration | None:
for it in self._store.values():
if it.session_id == session_id and it.iteration_index == iteration_index:
return it
return None

def get_latest_for_session(self, session_id: UUID) -> Iteration | None:
iters = [it for it in self._store.values() if it.session_id == session_id]
if not iters:
return None
return max(iters, key=lambda it: it.iteration_index)

def get_all_for_session(self, session_id: UUID) -> list[Iteration]:
return sorted(
[it for it in self._store.values() if it.session_id == session_id],
key=lambda it: it.iteration_index,
)

def update_status_optimistic(
self,
iteration_id: UUID,
expected_version: int,
status: IterationStatus,
**kwargs: object,
) -> Iteration | None:
iteration = self._store.get(iteration_id)
if not iteration or iteration.version != expected_version:
return None
iteration.status = status
iteration.version += 1
for k, v in kwargs.items():
if hasattr(iteration, k):
setattr(iteration, k, v)
return iteration

def update_selected_proposal(self, iteration_id: UUID, proposal_index: int) -> Iteration | None:
iteration = self._store.get(iteration_id)
if iteration:
iteration.selected_proposal_index = proposal_index
return iteration
49 changes: 49 additions & 0 deletions backend/app/repository/mock/mock_proposal_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from uuid import UUID

from app.model.session import Proposal, ProposalStatus


class MockProposalRepository:
def __init__(self) -> None:
self._store: dict[UUID, Proposal] = {}

def create(self, proposal: Proposal) -> Proposal:
self._store[proposal.id] = proposal
return proposal
Comment on lines +10 to +12

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

MockProposalRepository.create() uses proposal.id as the dict key, but SQLAlchemy defaults (UUID) may not be assigned until flush/commit. To keep unit tests realistic and avoid None keys, have the mock generate/set an ID when it’s missing before storing the proposal.

Copilot uses AI. Check for mistakes.

def get_by_id(self, proposal_id: UUID) -> Proposal | None:
return self._store.get(proposal_id)

def get_by_iteration_and_index(
self, iteration_id: UUID, proposal_index: int
) -> Proposal | None:
for p in self._store.values():
if p.iteration_id == iteration_id and p.proposal_index == proposal_index:
return p
return None

def get_all_by_status(self, status: ProposalStatus) -> list[Proposal]:
return [p for p in self._store.values() if p.status == status]

def get_all_for_iteration(self, iteration_id: UUID) -> list[Proposal]:
return sorted(
[p for p in self._store.values() if p.iteration_id == iteration_id],
key=lambda p: p.proposal_index,
)

def update_status_optimistic(
self,
proposal_id: UUID,
expected_version: int,
status: ProposalStatus,
**kwargs: object,
) -> Proposal | None:
proposal = self._store.get(proposal_id)
if not proposal or proposal.version != expected_version:
return None
proposal.status = status
proposal.version += 1
for k, v in kwargs.items():
if hasattr(proposal, k):
setattr(proposal, k, v)
return proposal
24 changes: 24 additions & 0 deletions backend/app/repository/mock/mock_session_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from uuid import UUID

from app.model.session import Session, SessionStatus


class MockSessionRepository:
def __init__(self) -> None:
self._store: dict[UUID, Session] = {}

def create(self, session: Session) -> Session:
self._store[session.id] = session
return session
Comment on lines +10 to +12

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

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

MockSessionRepository.create() assumes session.id is already populated. With SQLAlchemy models, mapped_column(default=uuid.uuid4) is typically assigned on flush/commit, so Session(...) may still have id=None in unit tests. To match the real repository contract (and avoid using None as a key), assign a UUID in the mock when session.id is missing.

Copilot uses AI. Check for mistakes.

def get_by_id(self, session_id: UUID) -> Session | None:
return self._store.get(session_id)

def update_status(self, session_id: UUID, status: SessionStatus) -> Session | None:
session = self._store.get(session_id)
if session:
session.status = status
return session

def list_all(self) -> list[Session]:
return list(self._store.values())
63 changes: 63 additions & 0 deletions backend/app/repository/protocols.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from typing import Protocol
from uuid import UUID

from app.model.session import (
Iteration,
IterationStatus,
Proposal,
ProposalStatus,
Session,
SessionStatus,
Setting,
)


class SessionRepositoryProtocol(Protocol):
def create(self, session: Session) -> Session: ...
def get_by_id(self, session_id: UUID) -> Session | None: ...
def update_status(self, session_id: UUID, status: SessionStatus) -> Session | None: ...
def list_all(self) -> list[Session]: ...


class IterationRepositoryProtocol(Protocol):
def create(self, iteration: Iteration) -> Iteration: ...
def get_by_id(self, iteration_id: UUID) -> Iteration | None: ...
def get_by_session_and_index(
self, session_id: UUID, iteration_index: int
) -> Iteration | None: ...
def get_latest_for_session(self, session_id: UUID) -> Iteration | None: ...
def get_all_for_session(self, session_id: UUID) -> list[Iteration]: ...
def update_status_optimistic(
self,
iteration_id: UUID,
expected_version: int,
status: IterationStatus,
**kwargs: object,
) -> Iteration | None: ...
def update_selected_proposal(
self, iteration_id: UUID, proposal_index: int
) -> Iteration | None: ...


class ProposalRepositoryProtocol(Protocol):
def create(self, proposal: Proposal) -> Proposal: ...
def get_by_id(self, proposal_id: UUID) -> Proposal | None: ...
def get_by_iteration_and_index(
self, iteration_id: UUID, proposal_index: int
) -> Proposal | None: ...
def get_all_by_status(self, status: ProposalStatus) -> list[Proposal]: ...
def get_all_for_iteration(self, iteration_id: UUID) -> list[Proposal]: ...
def update_status_optimistic(
self,
proposal_id: UUID,
expected_version: int,
status: ProposalStatus,
**kwargs: object,
) -> Proposal | None: ...


class SettingRepositoryProtocol(Protocol):
def get_by_key(self, key: str) -> Setting | None: ...
def upsert(self, key: str, value: str) -> Setting: ...
def list_all(self) -> list[Setting]: ...
def delete(self, key: str) -> bool: ...
Loading