EPIC R01 (Team Duy) - safety net before the parallel refactor starts.
R01-T01 docs/architecture/ADR-001-layered-architecture.md
4-tier boundaries, allowed dependency directions, invariants I1-I6 and
the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
13 snapshot tests pinning run_cowork's current observable contract before
EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
Import-graph scan: 43 unimported modules verified down to 6 genuinely
dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.
tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.
Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10 KiB
ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
- Status: Accepted
- Date: 2026-08-21
- EPIC / Task: R01-T01
- Owner: 🔵 Team Duy (Tech Lead)
- Áp dụng cho: toàn bộ mã nguồn mới của
cowork_local(3 team)
1. Context (Bối cảnh)
cowork_local hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
| Vấn đề | Bằng chứng cụ thể trong repo |
|---|---|
| God widget | ui/co4e_tab.py 2.089 dòng, ui/chat_panel.py 1.795 dòng, ui/folder_tab.py 1.590 dòng |
| Business logic nằm trong widget | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong ui/chat_panel.py |
| Logic trùng lặp 3 nơi | ui/chat_panel.py::_apply_routing, ui/co4e_tab.py::_apply_co4e_routing, ui/folder_tab.py::_ai_apply_routing là ba bản sao gần như y hệt của cùng một thuật toán |
| Không test được nếu không có Qt | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
| Side-effect ẩn trong tầng hạ tầng | Provider tự gọi core.usage_tracker.record() ngay trong vòng lặp stream (providers/openai_compat.py::_record_usage) |
Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu không có một ranh giới phụ thuộc được kiểm chứng tự động, các thay đổi song song sẽ hội tụ về đúng cấu trúc rối như cũ.
2. Decision (Quyết định)
Mã nguồn mới được tổ chức thành 4 tầng, với chiều phụ thuộc một chiều như sau:
┌─────────────────────────────────────────────────────────────┐
│ presentation/ PySide6 widgets, Qt signals/slots │
│ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ application/ Pure Python orchestration │
│ (conversations, Điều phối use-case, không biết Qt │
│ model_routing…) và không biết HTTP/đĩa cụ thể │
└───────────────────────────┬─────────────────────────────────┘
│ gọi xuống (được phép)
┌───────────────────────────▼─────────────────────────────────┐
│ domain/ Pure Python entities & events │
│ (agents, models…) Frozen dataclass, enum, quy tắc │
│ nghiệp vụ thuần. KHÔNG import gì │
│ từ 3 tầng còn lại. │
└───────────────────────────▲─────────────────────────────────┘
│ implement interface của domain
┌───────────────────────────┴─────────────────────────────────┐
│ infrastructure/ Adapters: network, keyring, đĩa, │
│ (providers, telemetry…) process, Qt-free I/O │
└─────────────────────────────────────────────────────────────┘
2.1 Quy tắc bất biến (Invariants)
| # | Quy tắc | Được kiểm bởi |
|---|---|---|
| I1 | domain/ và application/ là 100% pure Python — cấm import PySide6, PyQt5, PyQt6, shiboken6 |
scripts/check_imports.py (R01-T03) |
| I2 | domain/ không import application/, infrastructure/, presentation/, ui/ |
scripts/check_imports.py |
| I3 | application/ không import presentation/ hay ui/ |
scripts/check_imports.py |
| I4 | Không file production nào vượt 400 dòng | scripts/check_loc.py (R10-T02) |
| I5 | presentation/ không gọi thẳng provider/HTTP/đĩa — phải đi qua một application service |
Code review + I1–I3 |
| I6 | Mọi input của một use-case được đóng gói thành snapshot bất biến (frozen dataclass) trước khi rời UI thread |
Code review + unit test |
2.2 Chiều phụ thuộc được phép
| Từ tầng | Được import | Bị cấm |
|---|---|---|
presentation/ |
application/, domain/, PySide6 |
— (nên tránh gọi thẳng infrastructure/) |
application/ |
domain/, interface do domain/ định nghĩa |
presentation/, ui/, PySide6 |
domain/ |
chỉ stdlib | tất cả các tầng khác, PySide6 |
infrastructure/ |
domain/, thư viện ngoài (requests, keyring…) |
presentation/, ui/, PySide6 |
2.3 Cách tầng dưới "nói chuyện ngược" lên UI
application/ không được giữ tham chiếu tới widget. Việc trao đổi ngược chiều
đi qua callback thuần Python nhận một AgentEvent có kiểu
(domain/agents/agent_event.py, R04-T02):
# application layer — pure Python, không biết Qt tồn tại
service.run_turn(request, on_event=my_callback)
# presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
def my_callback(event: AgentEvent) -> None:
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
Đây là seam duy nhất giữa hai thế giới: dưới seam là Python thuần test được offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không bao giờ gọi trực tiếp từ worker thread.
3. Vị trí sở hữu theo team
| Tầng / thư mục | Team | EPIC |
|---|---|---|
presentation/chat/, application/conversations/, application/model_routing/, domain/agents/, domain/models/, infrastructure/providers/, infrastructure/telemetry/, tests/, scripts/ |
🔵 Duy | R01, R03, R04, R08, R10 |
presentation/co4e/, monitoring/, settings/, shell/, application/workflows/, infrastructure/config/, secrets/, sandbox/ |
🟣 Nam | R02, R08, R09 |
presentation/workspace/, folder/, scheduling/, application/workspaces/, scheduling/, domain/tools/, domain/tasks/, infrastructure/filesystem/, mcp/, persistence/ |
🟢 Hoa | R05, R06, R07, R08 |
4. Chiến lược di trú (Strangler Fig, không big-bang)
Code cũ trong core/, ui/, providers/ không bị xoá ngay. Ta bọc dần:
- Tạo seam mới ở tầng đúng (ví dụ
RoutingApplicationService). - Chuyển call site cũ sang gọi seam mới (
ui/*.pychỉ còn vài dòng adapter). - Giữ module cũ làm implementation detail phía sau seam (ví dụ
application/model_routing/vẫn gọi xuốngcore/routing/để dùng lại scorer/selector đã có test). - Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
Nhờ vậy pytest luôn xanh giữa các bước, và một team có thể merge mà không chờ
team khác refactor xong.
5. Consequences (Hệ quả)
Tích cực
- Test một quyết định routing / một vòng đời turn chat không cần Qt, không cần mạng → suite unit chạy < 1 giây.
- Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
- Người mới có thể thêm một provider mà chỉ chạm
infrastructure/providers/+domain/models/. - Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
Tiêu cực / chi phí phải chấp nhận
- Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
- Tồn tại hai đường trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
- Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng
selfcủa widget — tốn thêm code, đổi lại được thread-safety.
6. Alternatives considered (Phương án đã cân nhắc)
| Phương án | Lý do loại |
|---|---|
| Giữ nguyên, chỉ tách file cho ngắn | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
| MVVM/MVP thuần Qt | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
| Hexagonal đầy đủ (port/adapter cho mọi thứ) | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
| Big-bang rewrite | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
7. Enforcement (Thực thi)
python scripts/check_imports.py # I1, I2, I3 — quét AST
python scripts/check_loc.py # I4 — giới hạn 400 dòng
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào main.
8. Tài liệu liên quan
docs/refactor/Feature_Architecture_Proposal.md— thiết kế tổng thể 10 EPICdocs/refactor/Refactoring_Checklist.md— bảng tiến độ theo taskdocs/architecture/dormant-code.md— danh mục code không còn hoạt động (R01-T05)