Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## 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:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
@@ -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"]