merge: merge origin/gamma/refactor and origin/feature/teamhoa/r05-r06 into feature/delta-team/epic-R04
This commit is contained in:
@@ -1 +1 @@
|
||||
"""Infrastructure persistence package."""
|
||||
"""Persistence adapters (EPIC R02/R06)."""
|
||||
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user