[feat] add basic domain & usecase

This commit is contained in:
Azamat 2026-04-02 12:04:47 +03:00
parent f0c4988b44
commit 9083e33675
10 changed files with 137 additions and 5 deletions

View file

@ -31,7 +31,7 @@ test:
uv run pytest -v uv run pytest -v
lint: lint:
uv run ruff check . uv run ruff check . --fix
typecheck: typecheck:
uv run mypy . uv run mypy .

View file

@ -1,5 +1,6 @@
from collections.abc import Callable from collections.abc import Callable
from fastapi import FastAPI
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from adapter.config.loader import load_config from adapter.config.loader import load_config
@ -8,7 +9,6 @@ from adapter.di.container import AppContainer, build_container
from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE from adapter.http.fastapi.dependencies import APP_CONFIG_STATE, APP_CONTAINER_STATE
from adapter.http.fastapi.middleware import register_middleware from adapter.http.fastapi.middleware import register_middleware
from adapter.http.fastapi.routers.v1.router import router as v1_router from adapter.http.fastapi.routers.v1.router import router as v1_router
from fastapi import FastAPI
API_V1_PREFIX = '/api/v1' API_V1_PREFIX = '/api/v1'

View file

@ -1,7 +1,8 @@
from typing import cast from typing import cast
from adapter.di.container import AppContainer
from fastapi import Depends, Request from fastapi import Depends, Request
from adapter.di.container import AppContainer
from usecase.user import GetUser from usecase.user import GetUser
APP_CONTAINER_STATE = 'container' APP_CONTAINER_STATE = 'container'

View file

@ -1,8 +1,9 @@
from time import perf_counter from time import perf_counter
from fastapi import FastAPI, Request, Response
from adapter.config.model import AppConfig from adapter.config.model import AppConfig
from adapter.http.fastapi.dependencies import get_container from adapter.http.fastapi.dependencies import get_container
from fastapi import FastAPI, Request, Response
def register_middleware(app: FastAPI, config: AppConfig) -> None: def register_middleware(app: FastAPI, config: AppConfig) -> None:

View file

@ -0,0 +1,19 @@
# 006 MVP Docker Sandbox Orchestration
Context
- The service needs a first MVP for sandbox orchestration behind `/api/v1/create`.
- The first version must stay small, avoid auth, and preserve clean architecture boundaries.
Decision
- Use Docker as the outer runtime adapter for sandbox start and stop operations.
- Keep sandbox entities and errors in `domain/` and orchestration ports in `usecase/`.
- Use an in-memory session repository for the MVP instead of a central database.
- Keep one active sandbox per `chat_id` and reuse it until TTL expiry.
- Set default sandbox TTL to 300 seconds.
- Mount chat storage as `rw`, dependencies as `ro`, and lambda-tools as `ro`.
- Run expired sandbox cleanup as an in-process background loop in the HTTP app lifecycle.
Consequences
- Inner layers stay free from Docker and FastAPI details.
- The MVP is single-instance oriented and not yet suitable for multi-node coordination.
- Repository and runtime can be replaced later without changing usecase contracts.

View file

@ -16,3 +16,19 @@ class UserConflictError(UserError):
def __init__(self, email: str) -> None: def __init__(self, email: str) -> None:
super().__init__('user_conflict') super().__init__('user_conflict')
self.email = email self.email = email
class SandboxError(DomainError):
pass
class SandboxStartError(SandboxError):
def __init__(self, chat_id: str) -> None:
super().__init__('sandbox_start_failed')
self.chat_id = chat_id
class SandboxAlreadyRunningError(SandboxError):
def __init__(self, chat_id: str) -> None:
super().__init__('sandbox_already_running')
self.chat_id = chat_id

21
domain/sandbox.py Normal file
View file

@ -0,0 +1,21 @@
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
class SandboxStatus(str, Enum):
STARTING = 'starting'
RUNNING = 'running'
STOPPING = 'stopping'
STOPPED = 'stopped'
FAILED = 'failed'
@dataclass(frozen=True, slots=True)
class SandboxSession:
session_id: str
chat_id: str
container_id: str
status: SandboxStatus
created_at: datetime
expires_at: datetime

View file

@ -32,7 +32,7 @@
### M01. ADR и минимальный sandbox scaffolding ### M01. ADR и минимальный sandbox scaffolding
- Исполнитель: `primary-agent` - Исполнитель: `primary-agent`
- Статус: pending - Статус: completed
- Зависимости: нет - Зависимости: нет
- Commit required: no - Commit required: no
- Scope: зафиксировать MVP-решение в ADR и создать минимальные сущности, ошибки и usecase-контракты для sandbox orchestration - Scope: зафиксировать MVP-решение в ADR и создать минимальные сущности, ошибки и usecase-контракты для sandbox orchestration

View file

@ -1,7 +1,9 @@
from collections.abc import Mapping from collections.abc import Mapping
from datetime import datetime
from types import TracebackType from types import TracebackType
from typing import Protocol, TypeAlias from typing import Protocol, TypeAlias
from domain.sandbox import SandboxSession
from domain.user import User from domain.user import User
AttrValue: TypeAlias = str | int | float | bool AttrValue: TypeAlias = str | int | float | bool
@ -16,6 +18,32 @@ class UserRepository(Protocol):
def save(self, user: User) -> None: ... def save(self, user: User) -> None: ...
class SandboxSessionRepository(Protocol):
def get_active_by_chat_id(self, chat_id: str) -> SandboxSession | None: ...
def list_expired(self, now: datetime) -> list[SandboxSession]: ...
def save(self, session: SandboxSession) -> None: ...
def delete(self, session_id: str) -> None: ...
class SandboxRuntime(Protocol):
def create(
self,
*,
session_id: str,
chat_id: str,
expires_at: datetime,
) -> SandboxSession: ...
def stop(self, container_id: str) -> None: ...
class Clock(Protocol):
def now(self) -> datetime: ...
class Logger(Protocol): class Logger(Protocol):
def debug(self, message: str, attrs: Attrs | None = None) -> None: ... def debug(self, message: str, attrs: Attrs | None = None) -> None: ...

46
usecase/sandbox.py Normal file
View file

@ -0,0 +1,46 @@
from dataclasses import dataclass
from datetime import timedelta
from domain.sandbox import SandboxSession
from usecase.interface import Clock, Logger, SandboxRuntime, SandboxSessionRepository
@dataclass(frozen=True, slots=True)
class CreateSandboxCommand:
chat_id: str
class CreateSandbox:
def __init__(
self,
repository: SandboxSessionRepository,
runtime: SandboxRuntime,
clock: Clock,
logger: Logger,
ttl: timedelta,
) -> None:
self._repository = repository
self._runtime = runtime
self._clock = clock
self._logger = logger
self._ttl = ttl
def execute(self, command: CreateSandboxCommand) -> SandboxSession:
raise NotImplementedError
class CleanupExpiredSandboxes:
def __init__(
self,
repository: SandboxSessionRepository,
runtime: SandboxRuntime,
clock: Clock,
logger: Logger,
) -> None:
self._repository = repository
self._runtime = runtime
self._clock = clock
self._logger = logger
def execute(self) -> list[SandboxSession]:
raise NotImplementedError