add storage foundation contracts

This commit is contained in:
Azamat 2026-04-07 19:31:50 +03:00
parent 0ca0bac9bf
commit 5381c997e2
6 changed files with 208 additions and 0 deletions

View file

@ -0,0 +1,17 @@
# 009 Storage foundation
## Context
- v1 storage slice needs workspace, chat and file flows before durable DB
- trusted caller passes `user_id`, and one workspace belongs to one user
- chat content must live outside sandbox lifecycle and survive sandbox restart
## Decision
- metadata repositories are in-memory for the first storage slice
- `Workspace`, `Chat` and `ChatFile` are first-class domain entities
- filesystem access stays behind storage ports in outer layers
- sandbox later integrates through chat metadata and storage ports, not raw path math in usecases
## Consequences
- metadata is lost on restart in this phase
- storage usecases and HTTP API can be built before durable persistence
- later durable metadata can replace in-memory adapters behind the same ports

View file

@ -0,0 +1,17 @@
# 010 Chat history policy
## Context
- v1 keeps chat history in filesystem, not in central DB
- chat metadata must not depend on parsing history content
- each chat already maps to an isolated working directory
## Decision
- each chat owns one `history.md` inside its chat directory
- `history.md` is created with chat layout initialization
- chat metadata stores identity and lifecycle fields separately from history content
- history read and write stay behind storage ports in outer layers
## Consequences
- history survives sandbox restart with chat storage
- metadata and content evolve independently
- later migration to another history backend can keep the same chat identity model

35
domain/chat.py Normal file
View file

@ -0,0 +1,35 @@
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
HISTORY_FILE_NAME = 'history.md'
@dataclass(frozen=True, slots=True)
class ChatAttachmentName:
value: str
def __post_init__(self) -> None:
if not self.value or self.value in {'.', '..'}:
raise ValueError('invalid attachment name')
if '/' in self.value or '\\' in self.value:
raise ValueError('invalid attachment name')
if self.value == HISTORY_FILE_NAME:
raise ValueError('reserved attachment name')
@dataclass(frozen=True, slots=True)
class Chat:
chat_id: UUID
workspace_id: UUID
created_at: datetime
@dataclass(frozen=True, slots=True)
class ChatFile:
file_id: UUID
chat_id: UUID
name: ChatAttachmentName
content_type: str | None
size_bytes: int
created_at: datetime

View file

@ -1,3 +1,6 @@
from uuid import UUID
class DomainError(Exception): class DomainError(Exception):
pass pass
@ -18,6 +21,48 @@ class UserConflictError(UserError):
self.email = email self.email = email
class WorkspaceError(DomainError):
pass
class WorkspaceNotFoundError(WorkspaceError):
def __init__(self, workspace_id: UUID) -> None:
super().__init__('workspace_not_found')
self.workspace_id = workspace_id
class WorkspaceQuotaExceededError(WorkspaceError):
def __init__(self, workspace_id: UUID) -> None:
super().__init__('workspace_quota_exceeded')
self.workspace_id = workspace_id
class ChatError(DomainError):
pass
class ChatNotFoundError(ChatError):
def __init__(self, chat_id: UUID) -> None:
super().__init__('chat_not_found')
self.chat_id = chat_id
class ChatHasActiveSandboxError(ChatError):
def __init__(self, chat_id: UUID) -> None:
super().__init__('chat_has_active_sandbox')
self.chat_id = chat_id
class ChatFileError(DomainError):
pass
class ChatFileNotFoundError(ChatFileError):
def __init__(self, file_id: UUID) -> None:
super().__init__('chat_file_not_found')
self.file_id = file_id
class SandboxError(DomainError): class SandboxError(DomainError):
pass pass

17
domain/workspace.py Normal file
View file

@ -0,0 +1,17 @@
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
@dataclass(frozen=True, slots=True)
class Workspace:
workspace_id: UUID
user_id: UUID
created_at: datetime
@dataclass(frozen=True, slots=True)
class WorkspaceUsage:
workspace_id: UUID
used_bytes: int
quota_bytes: int

View file

@ -4,8 +4,10 @@ from types import TracebackType
from typing import Protocol, TypeAlias from typing import Protocol, TypeAlias
from uuid import UUID from uuid import UUID
from domain.chat import Chat, ChatAttachmentName, ChatFile
from domain.sandbox import SandboxSession from domain.sandbox import SandboxSession
from domain.user import User from domain.user import User
from domain.workspace import Workspace, WorkspaceUsage
AttrValue: TypeAlias = str | int | float | bool AttrValue: TypeAlias = str | int | float | bool
Attrs: TypeAlias = Mapping[str, AttrValue] Attrs: TypeAlias = Mapping[str, AttrValue]
@ -19,6 +21,81 @@ class UserRepository(Protocol):
def save(self, user: User) -> None: ... def save(self, user: User) -> None: ...
class WorkspaceRepository(Protocol):
def get(self, workspace_id: UUID) -> Workspace | None: ...
def get_by_user_id(self, user_id: UUID) -> Workspace | None: ...
def save(self, workspace: Workspace) -> None: ...
class ChatRepository(Protocol):
def get(self, chat_id: UUID) -> Chat | None: ...
def list_by_workspace_id(self, workspace_id: UUID) -> list[Chat]: ...
def save(self, chat: Chat) -> None: ...
def delete(self, chat_id: UUID) -> None: ...
class ChatFileRepository(Protocol):
def get(self, file_id: UUID) -> ChatFile | None: ...
def get_by_chat_id_and_name(
self,
chat_id: UUID,
name: ChatAttachmentName,
) -> ChatFile | None: ...
def list_by_chat_id(self, chat_id: UUID) -> list[ChatFile]: ...
def save(self, chat_file: ChatFile) -> None: ...
def delete(self, file_id: UUID) -> None: ...
def delete_by_chat_id(self, chat_id: UUID) -> None: ...
class ChatStorage(Protocol):
def ensure_chat(self, chat: Chat) -> None: ...
def read_history(self, chat: Chat) -> str: ...
def write_history(self, chat: Chat, content: str) -> None: ...
def delete_chat(self, chat: Chat) -> None: ...
def write_attachment(
self,
chat: Chat,
file_name: ChatAttachmentName,
content: bytes,
) -> int: ...
def read_attachment(self, chat: Chat, file_name: ChatAttachmentName) -> bytes: ...
def delete_attachment(
self,
chat: Chat,
file_name: ChatAttachmentName,
) -> None: ...
def clear_attachments(self, chat: Chat) -> None: ...
class StorageUsageReader(Protocol):
def get_workspace_usage(
self,
workspace: Workspace,
chats: list[Chat],
) -> WorkspaceUsage: ...
class IdGenerator(Protocol):
def new(self) -> UUID: ...
class SandboxSessionRepository(Protocol): class SandboxSessionRepository(Protocol):
def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ... def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...