From 5381c997e2fc1307c5e8de5ce3ee9b7f63b00b94 Mon Sep 17 00:00:00 2001 From: Azamat Date: Tue, 7 Apr 2026 19:31:50 +0300 Subject: [PATCH] add storage foundation contracts --- docs/009-storage-foundation.md | 17 ++++++++ docs/010-chat-history-policy.md | 17 ++++++++ domain/chat.py | 35 +++++++++++++++ domain/error.py | 45 +++++++++++++++++++ domain/workspace.py | 17 ++++++++ usecase/interface.py | 77 +++++++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+) create mode 100644 docs/009-storage-foundation.md create mode 100644 docs/010-chat-history-policy.md create mode 100644 domain/chat.py create mode 100644 domain/workspace.py diff --git a/docs/009-storage-foundation.md b/docs/009-storage-foundation.md new file mode 100644 index 0000000..4aadf58 --- /dev/null +++ b/docs/009-storage-foundation.md @@ -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 diff --git a/docs/010-chat-history-policy.md b/docs/010-chat-history-policy.md new file mode 100644 index 0000000..39c2125 --- /dev/null +++ b/docs/010-chat-history-policy.md @@ -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 diff --git a/domain/chat.py b/domain/chat.py new file mode 100644 index 0000000..3dbd7cb --- /dev/null +++ b/domain/chat.py @@ -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 diff --git a/domain/error.py b/domain/error.py index f691113..ff3486e 100644 --- a/domain/error.py +++ b/domain/error.py @@ -1,3 +1,6 @@ +from uuid import UUID + + class DomainError(Exception): pass @@ -18,6 +21,48 @@ class UserConflictError(UserError): 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): pass diff --git a/domain/workspace.py b/domain/workspace.py new file mode 100644 index 0000000..3526203 --- /dev/null +++ b/domain/workspace.py @@ -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 diff --git a/usecase/interface.py b/usecase/interface.py index 69876e6..de681d6 100644 --- a/usecase/interface.py +++ b/usecase/interface.py @@ -4,8 +4,10 @@ from types import TracebackType from typing import Protocol, TypeAlias from uuid import UUID +from domain.chat import Chat, ChatAttachmentName, ChatFile from domain.sandbox import SandboxSession from domain.user import User +from domain.workspace import Workspace, WorkspaceUsage AttrValue: TypeAlias = str | int | float | bool Attrs: TypeAlias = Mapping[str, AttrValue] @@ -19,6 +21,81 @@ class UserRepository(Protocol): 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): def get_active_by_chat_id(self, chat_id: UUID) -> SandboxSession | None: ...