merge: merge origin/gamma/refactor and origin/feature/teamhoa/r05-r06 into feature/delta-team/epic-R04

This commit is contained in:
2026-08-27 12:23:43 +09:00
57 changed files with 3003 additions and 512 deletions
+1 -1
View File
@@ -1 +1 @@
"""Infrastructure persistence package."""
"""Persistence adapters (EPIC R02/R06)."""
+13 -1
View File
@@ -1 +1,13 @@
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories."""
"""JSON-file persistence adapters: crash-safe writes, AtomicJsonFile and repositories."""
from .atomic_json_file import AtomicJsonFile
from .atomic_write import write_json
from .conversation_repository_impl import ConversationRepository
from .workspace_repository_impl import WorkspaceRepository
__all__ = [
"AtomicJsonFile",
"write_json",
"WorkspaceRepository",
"ConversationRepository",
]
@@ -0,0 +1,56 @@
"""write_json - crash-safe JSON writes (R06-T02).
``core/projects.py::save_project`` and ``core/history.py``'s
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
between: a crash, a killed process, or a full disk between the truncate and
the write leaves a half-written, unparseable JSON file - the NEXT read of
that project/conversation then fails outright (``load_project`` /
``load_conversation`` already treat a parse error as "missing", so this isn't
even a loud failure - a project can silently vanish).
``write_json`` fixes this the standard way: write the full content to a
temporary file in the SAME directory (so the following replace is on one
filesystem, not crossing a mount point), then atomically rename it over the
target. Either the old file is still there, or the new one is fully there -
never a partial one.
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
for the SAME purpose across the whole app (config, secrets, ...). This module
is deliberately named differently and scoped to R06's two repositories only,
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
this module can go away.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def write_json(path: Path, data: Any) -> None:
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
atomically. Creates parent directories if needed."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=2)
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
__all__ = ["write_json"]
@@ -0,0 +1,63 @@
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
``core/history.py`` (R06-T02).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.core.history import (
delete_conversation,
list_conversations,
load_conversation,
new_session_id,
rename_conversation,
save_conversation,
set_pinned,
)
class ConversationRepository:
"""CRUD + search over conversation JSON files, scoped to one
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
def __init__(self, directory: Optional[Path] = None) -> None:
if directory is not None:
self._directory = Path(directory)
else:
from cowork_local.config import HISTORY_DIR
self._directory = HISTORY_DIR
def new_session_id(self) -> str:
return new_session_id()
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
def load(self, path: Path) -> Dict[str, Any]:
return load_conversation(path)
def list(self, query: str = "", **kwargs) -> List[Dict[str, Any]]:
return list_conversations(self._directory, query=query)
def _resolve_path(self, target: Any) -> Path:
if isinstance(target, Path):
return target
p = Path(str(target))
if p.exists() or p.is_absolute():
return p
for file in self._directory.glob(f"*__{target}.json"):
return file
return self._directory / f"cowork__{target}.json"
def rename(self, target: Any, new_title: str) -> None:
rename_conversation(self._resolve_path(target), new_title)
def delete(self, target: Any) -> None:
delete_conversation(self._resolve_path(target))
def set_pinned(self, target: Any, pinned: bool) -> None:
set_pinned(self._resolve_path(target), pinned)
__all__ = ["ConversationRepository"]
@@ -0,0 +1,49 @@
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
``core/projects.py`` (R06-T02).
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING:
from cowork_local.core.projects import Project
class WorkspaceRepository:
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
``tmp_path`` so nothing touches the user's real config folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
if directory is not None:
self._directory = Path(directory)
else:
from cowork_local.core.projects import PROJECTS_DIR
self._directory = PROJECTS_DIR
def list(self) -> List["Project"]:
from cowork_local.core.projects import list_projects
return list_projects(self._directory)
def get(self, project_id: str) -> Optional["Project"]:
from cowork_local.core.projects import load_project
return load_project(project_id, self._directory)
def save(self, project: "Project") -> None:
from cowork_local.core.projects import save_project
save_project(project, self._directory)
def create(self, name: str, **kwargs) -> "Project":
from cowork_local.core.projects import new_project
return new_project(name, directory=self._directory, **kwargs)
def new(self, name: str, **kwargs) -> "Project":
return self.create(name, **kwargs)
def delete(self, project_id: str) -> bool:
from cowork_local.core.projects import delete_project
return delete_project(project_id, self._directory)
__all__ = ["WorkspaceRepository"]