## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""JSON-file persistence adapters: crash-safe writes and the workspace/
|
||||
conversation/task repositories built on them (EPIC R06, R07)."""
|
||||
|
||||
from .atomic_json_file import AtomicJsonFile
|
||||
from .atomic_write import write_json
|
||||
from .conversation_repository_impl import ConversationRepository
|
||||
from .task_repository_impl import TaskRepository
|
||||
from .workspace_repository_impl import WorkspaceRepository
|
||||
|
||||
__all__ = [
|
||||
"AtomicJsonFile",
|
||||
"write_json",
|
||||
"WorkspaceRepository",
|
||||
"ConversationRepository",
|
||||
"TaskRepository",
|
||||
]
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Ghi JSON kiểu không-hỏng-file — R02-T01.
|
||||
|
||||
Vấn đề đang có: ``config.py::save()`` gọi thẳng ``path.write_text(...)``. Hàm
|
||||
đó mở file, cắt cụt về 0 byte, rồi mới ghi nội dung mới. Mất điện, tắt máy, hay
|
||||
process bị kill đúng khoảng giữa thì file cấu hình còn lại **rỗng hoặc ghi dở**
|
||||
— và người dùng mất toàn bộ cấu hình.
|
||||
|
||||
Cách làm ở đây theo đúng thứ tự bắt buộc:
|
||||
|
||||
1. Ghi vào file tạm cùng thư mục (phải cùng ổ đĩa thì bước 3 mới nguyên tử)
|
||||
2. ``flush()`` + ``os.fsync()`` — ép dữ liệu xuống đĩa thật, không nằm trong
|
||||
bộ đệm của hệ điều hành
|
||||
3. ``os.replace()`` — nguyên tử trên cả Windows lẫn POSIX
|
||||
|
||||
Bất kỳ lúc nào chết giữa chừng, file đích vẫn là **bản cũ nguyên vẹn**. Không
|
||||
bao giờ có trạng thái ghi dở.
|
||||
|
||||
Phần đọc có chính sách phục hồi: file hỏng thì giữ lại thành ``.bad`` để còn
|
||||
cứu tay, rồi trả về giá trị mặc định — hỏng cấu hình không được chặn khởi động,
|
||||
đúng như ``config.py`` hiện tại đang làm.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AtomicJsonFile:
|
||||
"""Một file JSON, đọc ghi an toàn.
|
||||
|
||||
>>> f = AtomicJsonFile(Path("cau_hinh.json"))
|
||||
>>> f.write({"theme": "dark"})
|
||||
>>> f.read(default={})
|
||||
{'theme': 'dark'}
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, indent: int = 2):
|
||||
"""Trỏ vào một file JSON. ``indent`` giữ file còn đọc và so sánh được bằng mắt
|
||||
trong git diff.
|
||||
"""
|
||||
self.path = Path(path)
|
||||
self.indent = indent
|
||||
|
||||
# ---- đọc ------------------------------------------------------------
|
||||
def read(self, default: Any = None) -> Any:
|
||||
"""Nội dung file, hoặc ``default`` nếu chưa có / hỏng.
|
||||
|
||||
Không ném lỗi. File hỏng được đổi tên thành ``<tên>.bad-<thời điểm>``
|
||||
rồi mới trả mặc định — hỏng thì cứu được, chứ đừng ghi đè im lặng.
|
||||
"""
|
||||
if not self.path.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
self._quarantine()
|
||||
return default
|
||||
except OSError:
|
||||
# Không đọc được (khoá file, mất quyền) — KHÔNG cách ly, vì file
|
||||
# có thể vẫn tốt nguyên.
|
||||
return default
|
||||
|
||||
def _quarantine(self) -> Path | None:
|
||||
"""Đổi tên file JSON hỏng thành ``.bad-<mốc thời gian>`` thay vì xoá.
|
||||
|
||||
Giữ lại để còn cứu dữ liệu, và để lần ghi sau bắt đầu từ file sạch.
|
||||
"""
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = self.path.with_suffix(self.path.suffix + f".bad-{stamp}")
|
||||
try:
|
||||
os.replace(self.path, target)
|
||||
return target
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
# ---- ghi ------------------------------------------------------------
|
||||
#: Số lần thử lại ``os.replace`` và khoảng nghỉ giữa các lần (giây).
|
||||
_REPLACE_TRIES = 6
|
||||
_REPLACE_BACKOFF = 0.02
|
||||
|
||||
@classmethod
|
||||
def _replace_ben_bi(cls, src: Path, dst: Path) -> None:
|
||||
"""``os.replace`` có thử lại — bắt buộc trên Windows.
|
||||
|
||||
MoveFileEx trả ERROR_ACCESS_DENIED khi có tiến trình khác đang giữ
|
||||
handle lên nguồn hoặc đích. Trên Windows thật thì gần như luôn là
|
||||
Defender hoặc Search Indexer quét file vừa tạo, giữ handle vài chục
|
||||
mili-giây rồi nhả. Không phải lỗi quyền thật, thử lại là hết.
|
||||
|
||||
Đo trên máy dev 25/08: hỏng 1 trong 7 lượt chạy 20 lần ghi, tức
|
||||
khoảng 1 trên 140 lần lưu. Không có vòng này thì người dùng thỉnh
|
||||
thoảng bấm Lưu là văng lỗi mà không tài nào tái hiện.
|
||||
|
||||
POSIX không có kiểu hỏng này nên vòng lặp chạy đúng một lượt.
|
||||
"""
|
||||
for lan in range(cls._REPLACE_TRIES):
|
||||
try:
|
||||
os.replace(src, dst)
|
||||
return
|
||||
except PermissionError:
|
||||
if lan == cls._REPLACE_TRIES - 1:
|
||||
raise
|
||||
time.sleep(cls._REPLACE_BACKOFF * (2 ** lan))
|
||||
|
||||
def write(self, data: Any) -> None:
|
||||
"""Ghi ``data``. Hoặc thành công trọn vẹn, hoặc file cũ còn nguyên."""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = json.dumps(data, indent=self.indent, ensure_ascii=False)
|
||||
|
||||
# File tạm phải nằm CÙNG thư mục: os.replace chỉ nguyên tử trong cùng
|
||||
# một hệ thống tệp. Để ở %TEMP% là có thể rơi sang ổ khác và biến
|
||||
# thành copy + delete — mất luôn tính nguyên tử.
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(self.path.parent), prefix=f".{self.path.name}.", suffix=".tmp")
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
os.fsync(f.fileno()) # xuống đĩa thật, không chỉ vào bộ đệm
|
||||
self._replace_ben_bi(tmp, self.path) # nguyên tử, có thử lại
|
||||
except BaseException:
|
||||
# Kể cả KeyboardInterrupt/SystemExit cũng phải dọn file tạm, đừng
|
||||
# để rác .tmp nằm lại cạnh file cấu hình.
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
# ---- tiện ích -------------------------------------------------------
|
||||
def exists(self) -> bool:
|
||||
"""File đã tồn tại trên đĩa chưa."""
|
||||
return self.path.exists()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Biểu diễn ngắn kèm đường dẫn, cho log và thông báo lỗi."""
|
||||
return f"AtomicJsonFile({self.path})"
|
||||
@@ -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,81 @@
|
||||
"""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:
|
||||
"""``directory`` để None thì dùng thư mục lịch sử mặc định.
|
||||
|
||||
Import muộn ngay trong thân hàm để nạp module này không kéo theo cả cây cấu
|
||||
hình — test trỏ thẳng vào ``tmp_path``.
|
||||
"""
|
||||
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:
|
||||
"""Sinh id phiên mới cho một cuộc hội thoại."""
|
||||
return new_session_id()
|
||||
|
||||
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
|
||||
"""Ghi hội thoại xuống đĩa (ghi nguyên tử) và trả về đường dẫn file."""
|
||||
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
|
||||
|
||||
def load(self, path: Path) -> Dict[str, Any]:
|
||||
"""Đọc một hội thoại từ đường dẫn file."""
|
||||
return load_conversation(path)
|
||||
|
||||
def list(self, query: str = "", **kwargs) -> List[Dict[str, Any]]:
|
||||
"""Liệt kê hội thoại trong thư mục; ``query`` lọc theo tiêu đề và nội dung."""
|
||||
return list_conversations(self._directory, query=query)
|
||||
|
||||
def _resolve_path(self, target: Any) -> Path:
|
||||
"""Đổi id phiên (hoặc đường dẫn) thành đường dẫn file thật.
|
||||
|
||||
Nhận cả ba dạng: Path sẵn, đường dẫn tuyệt đối, và id phiên trần —
|
||||
id trần thì dò theo mẫu ``*__<id>.json`` vì tiền tố là loại hội thoại
|
||||
(cowork/co4e/...) mà chỗ gọi không phải lúc nào cũng biết.
|
||||
"""
|
||||
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:
|
||||
"""Đổi tiêu đề một hội thoại."""
|
||||
rename_conversation(self._resolve_path(target), new_title)
|
||||
|
||||
def delete(self, target: Any) -> None:
|
||||
"""Xoá hẳn một hội thoại khỏi đĩa."""
|
||||
delete_conversation(self._resolve_path(target))
|
||||
|
||||
def set_pinned(self, target: Any, pinned: bool) -> None:
|
||||
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách lịch sử."""
|
||||
set_pinned(self._resolve_path(target), pinned)
|
||||
|
||||
|
||||
__all__ = ["ConversationRepository"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""TaskRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/tasks.py`` (R07-T01).
|
||||
|
||||
``core/tasks.py``'s module-level functions (``list_tasks``, ``load_task``,
|
||||
``save_task``, ``delete_task``, ``new_task``, ``duplicate_task``) are still
|
||||
what every existing call site (``core/task_scheduler.py``,
|
||||
``core/task_executors.py``, ``ui/schedule_task_tab.py``) uses, and stay that
|
||||
way - ``save_task`` now writes through :func:`atomic_write.write_json`
|
||||
itself (R07-T01, same class of durability fix already applied to
|
||||
``core/projects.py``/``core/history.py`` at R06-T02), so the fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer
|
||||
(``application/scheduling``, R07-T04) to depend on an interface instead of
|
||||
reaching into ``core/`` directly. It is a thin pass-through today, not a
|
||||
re-implementation: same on-disk format, same directory, same functions
|
||||
underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
"""CRUD over task dicts (see ``core/tasks.py::DEFAULT_TASK`` for shape),
|
||||
scoped to one ``directory`` (defaults to the app's real ``TASKS_DIR``;
|
||||
tests pass a ``tmp_path`` so nothing touches the user's real config
|
||||
folder)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
"""``directory`` để None thì dùng thư mục task mặc định.
|
||||
|
||||
Hai đường import cho cùng một hằng số: gói có thể được nạp dưới tên đầy đủ
|
||||
``cowork_local`` hoặc dưới dạng tương đối tuỳ cách chạy.
|
||||
"""
|
||||
if directory is None:
|
||||
try:
|
||||
from cowork_local.core.tasks import TASKS_DIR
|
||||
except ImportError:
|
||||
from ...core.tasks import TASKS_DIR
|
||||
self._directory = TASKS_DIR
|
||||
else:
|
||||
self._directory = directory
|
||||
|
||||
def list(self) -> List[Dict[str, Any]]:
|
||||
"""Liệt kê mọi task trong thư mục."""
|
||||
try:
|
||||
from cowork_local.core.tasks import list_tasks
|
||||
except ImportError:
|
||||
from ...core.tasks import list_tasks
|
||||
return list_tasks(self._directory)
|
||||
|
||||
def get(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Đọc một task theo id; trả về ``None`` nếu không có."""
|
||||
try:
|
||||
from cowork_local.core.tasks import load_task
|
||||
except ImportError:
|
||||
from ...core.tasks import load_task
|
||||
return load_task(task_id, self._directory)
|
||||
|
||||
def save(self, task: Dict[str, Any]) -> Path:
|
||||
"""Ghi task xuống đĩa (ghi nguyên tử) và trả về đường dẫn file."""
|
||||
try:
|
||||
from cowork_local.core.tasks import save_task
|
||||
except ImportError:
|
||||
from ...core.tasks import save_task
|
||||
return save_task(task, self._directory)
|
||||
|
||||
def create(self, title: str = "", **overrides: Any) -> Dict[str, Any]:
|
||||
"""Tạo một task mới trong bộ nhớ theo mẫu mặc định; chưa ghi đĩa."""
|
||||
try:
|
||||
from cowork_local.core.tasks import new_task
|
||||
except ImportError:
|
||||
from ...core.tasks import new_task
|
||||
return new_task(title, **overrides)
|
||||
|
||||
def duplicate(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Nhân bản một task (id mới, trạng thái và lịch sử chạy được đặt lại)."""
|
||||
try:
|
||||
from cowork_local.core.tasks import duplicate_task
|
||||
except ImportError:
|
||||
from ...core.tasks import duplicate_task
|
||||
return duplicate_task(task)
|
||||
|
||||
def delete(self, task_id: str) -> None:
|
||||
"""Xoá hẳn một task khỏi đĩa."""
|
||||
try:
|
||||
from cowork_local.core.tasks import delete_task
|
||||
except ImportError:
|
||||
from ...core.tasks import delete_task
|
||||
delete_task(task_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["TaskRepository"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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:
|
||||
"""``directory`` để None thì dùng thư mục dự án mặc định; import muộn để không
|
||||
kéo cấu hình vào lúc nạp module.
|
||||
"""
|
||||
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"]:
|
||||
"""Liệt kê mọi project trong thư mục."""
|
||||
from cowork_local.core.projects import list_projects
|
||||
return list_projects(self._directory)
|
||||
|
||||
def get(self, project_id: str) -> Optional["Project"]:
|
||||
"""Đọc một project theo id; trả về ``None`` nếu không có."""
|
||||
from cowork_local.core.projects import load_project
|
||||
return load_project(project_id, self._directory)
|
||||
|
||||
def save(self, project: "Project") -> None:
|
||||
"""Ghi project xuống đĩa (ghi nguyên tử)."""
|
||||
from cowork_local.core.projects import save_project
|
||||
save_project(project, self._directory)
|
||||
|
||||
def create(self, name: str, **kwargs) -> "Project":
|
||||
"""Tạo project mới và ghi ngay xuống đĩa."""
|
||||
from cowork_local.core.projects import new_project
|
||||
return new_project(name, directory=self._directory, **kwargs)
|
||||
|
||||
def new(self, name: str, **kwargs) -> "Project":
|
||||
"""Bí danh của :meth:`create` — giữ cho mã cũ gọi ``new()`` vẫn chạy."""
|
||||
return self.create(name, **kwargs)
|
||||
|
||||
def delete(self, project_id: str) -> bool:
|
||||
"""Xoá project; trả về ``True`` nếu có project để xoá."""
|
||||
from cowork_local.core.projects import delete_project
|
||||
return delete_project(project_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceRepository"]
|
||||
Reference in New Issue
Block a user